React Context Provider 收到 subset 就会静默失效
一个多分区表单,必填区的省份字段是选填区城市字段的「上级」。城市字段却一直显示「请先选择省份」,即便省份已经选好。两个字段都挂在同一个级联 Context,单测全绿,集成一跑就挂——这种"离奇"现象,根因出在 Provider 拿到的 state 范围上。
现象
表单拆成必填、选填、只读三个 Section,每个 Section 各自包一个 CascadeGroupProvider:
// 反例:按视觉分区各自传 subset
<Section title="必填">
<CascadeGroupProvider fields={requiredItems} values={requiredValues}>
{requiredItems.map(renderField)}
</CascadeGroupProvider>
</Section>
<Section title="选填">
<CascadeGroupProvider fields={optionalItems} values={optionalValues}>
{optionalItems.map(renderField)}
</CascadeGroupProvider>
</Section>省份(parent)在必填区,城市(child)在选填区。选填区的 Provider 的 fields 里没有省份,values 里也没有省份的值。城市字段调用 useCascadeField('city') → getFieldHandle 找不到 parent → 判定为「上级未选」→ 渲染 disabled 并提示「请先选择省份」。
第二种变体:同一页面有两组级联(比如「职称系列」和「省市」),你嵌套了两个同类型 Provider:
<CascadeGroupProvider fields={titleSeriesGroup} values={formValues}>
<CascadeGroupProvider fields={provinceCityGroup} values={formValues}>
{children}
</CascadeGroupProvider>
</CascadeGroupProvider>useContext(CascadeGroupCtx) 只取最近的 Provider,外层的职称系列对内层完全不可见。内层找不到 titleSeries 字段,直接返回 null。
根因
Provider 的设计职责是「跨 subtree 协调状态」,但接收的数据却被按渲染范围切片了,两者方向相反。
useContext 的语义是单链向上找最近的一个 Provider,没有"合并多个同类 Provider"的能力。把状态按视觉分区切开再分别传给多个 Provider,每个 Provider 就只能看到自己那一片,跨区块的依赖关系在 context 层面断掉了。
用分区布局来组织 UI 是合理的,但不能同时用分区来切割 Provider 的状态范围。一旦两个字段之间存在跨区块依赖(级联、联动、校验),它们就必须共享同一个拥有完整 state 的 Provider。
常见误判信号:
- 写了
values={pickBy(formValues, sectionKeys)} renderField根据字段所属分区决定用哪个 Provider 包裹它- "在一个 Section 里测没问题,拆开就挂"
正解
方案一:一个 Provider 包所有分区,内部保留视觉布局
// 正解:一个 Provider 拿完整 state,Section 只管布局
<CascadeGroupProvider fields={entireCascadeGroup} values={entireFormValues}>
<Section title="必填">{requiredItems.map(renderField)}</Section>
<Section title="选填">{optionalItems.map(renderField)}</Section>
<Section title="只读">{readonlyItems.map(renderField)}</Section>
</CascadeGroupProvider>布局不变,Provider 上移一层到所有 Section 的公共祖先。任何字段调用 useCascadeField(key) 时,Provider 都能看到完整的 fields 和 values,跨区块依赖正常工作。
方案二:多个嵌套 Provider 时实现链式回退
如果确实需要多个同类型 Provider(例如两个独立级联组),让每个 Provider 在初始化时用 useContext(self) 捕获父 Provider,找不到本地字段时向上委托:
export function CascadeGroupProvider({ fields, values, children }) {
const parentCtx = useContext(CascadeGroupCtx) // 捕获外层 Provider
const ctxValue = useMemo(() => {
const parentLookup = parentCtx
? (k: string) => parentCtx.getFieldHandle(k)
: null
const getFieldHandle = (fieldKey: string) => {
const field = fields.find((f) => f.key === fieldKey)
if (!field) return parentLookup ? parentLookup(fieldKey) : null
const handle = resolveLocalHandle(fieldKey, field, values)
if (!handle) return parentLookup ? parentLookup(fieldKey) : null
return handle
}
return { getFieldHandle }
}, [fields, values, parentCtx])
return (
<CascadeGroupCtx.Provider value={ctxValue}>
{children}
</CascadeGroupCtx.Provider>
)
}内层找不到的 key 自动委托给外层,不会静默丢失。
- 把 Provider 上移到「所有互相依赖的字段」的最小公共祖先。
- 多个同类 Provider 嵌套时,实现链式委托(
useContext(self)+ parent fallback)。 - 用一个集成测试锁死契约:「把字段 A 从分区 1 移到分区 2,字段 B 对 A 的依赖仍然生效」。
一句话外卖
Context Provider 是协调契约,不是渲染容器——它的状态范围必须覆盖所有依赖方,而不是跟着视觉布局走。