返回博客

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 范围 >= 所有依赖该 Context 的字段范围
  • 把 Provider 上移到「所有互相依赖的字段」的最小公共祖先。
  • 多个同类 Provider 嵌套时,实现链式委托(useContext(self) + parent fallback)。
  • 用一个集成测试锁死契约:「把字段 A 从分区 1 移到分区 2,字段 B 对 A 的依赖仍然生效」。

一句话外卖

Context Provider 是协调契约,不是渲染容器——它的状态范围必须覆盖所有依赖方,而不是跟着视觉布局走。