React中的Context与状态管理边界

解释 React 中状态所有权、Context 传递、Reducer 演化、服务端缓存与外部 Store 的职责边界。

#tech / dev / frame #resource / react #type / synthesis #status / growing

[!info] related notes

React中的Context与状态管理边界

为什么这些概念必须一起理解

Context 解决的是“子树中的消费者如何拿到一个值”,不负责决定状态由谁拥有、如何转移、怎样缓存或如何调试。把传递、演化和远程同步混成一个问题,往往会得到一个频繁变化的大 Context。

先判断数据属于哪一类

数据默认归属典型工具
单个组件的输入、开关、草稿最近的共同组件useState
多分支但局部的状态转移最近的共同组件useReducer
子树共享的配置或模块能力Provider 所在边界Context
可缓存、可失效、可重取的服务端快照Query cacheTanStack Query
高频跨页面客户端业务状态外部 StoreZustand / Redux 等

Context 可以承载其中某一类数据,但“放进 Context”不会自动把它变成良好设计的全局状态。

类型安全 Context 的两个入口

有真实默认值

主题等配置可以拥有合法默认值:

type Theme = 'light' | 'dark' | 'system';
const ThemeContext = createContext<Theme>('system');

Provider 缺失时仍能产生语义正确的结果。

没有真实默认值

业务动作或依赖对象通常不能伪造:

const ActionsContext = createContext<Actions | null>(null);

function useActions(): Actions {
  const value = useContext(ActionsContext);
  if (value === null) {
    throw new Error('useActions must be used within ActionsProvider');
  }
  return value;
}

T | null 准确表达“Provider 可能缺失”,自定义 Hook 把检查集中在一处,并向后续业务代码返回非空 T

为什么拆分 state 与 actions Context

Context 根据 Object.is 比较 Provider 的 value。只要 value 身份变化,所有读取该 Context 的消费者都会重新渲染。

如果一个对象同时包含高频 state 和稳定 actions:

{ state, dispatchEvent, resetTurn }

每次 state 更新都会创建新对象,连只需要按钮动作的组件也会重新渲染。拆成两个 Context 后:

StateContext   -> 高频变化,状态消费者重渲染
ActionsContext -> 稳定值,只使用动作的消费者保持稳定

这不是“Context 必须永远拆分”的规则;只有更新频率和消费方式确实不同,拆分才有收益。

稳定引用不等于无需思考

  • useReducer 返回的 dispatch 身份稳定。
  • 包装 dispatch 的函数只有在确实需要语义化 API 时才有价值。
  • Provider value 是对象时,需要关注对象身份;可用 useMemo,或在值永不变化时用 useRef 保存。
  • React Compiler 能减少部分手工记忆化需求,但不能修复错误的状态所有权或过大的 Context 边界。

即使 actions 稳定,读取 StateContext 的组件仍会在每次 state value 变化时重新渲染。若消费者只关心很小切片且更新极高频,应考虑拆更细的 Context、使用带 selector 的外部 Store,或把状态下沉。

BodySense:Active Turn 的真实设计

ActiveTurnContext.tsx 展示了一条完整类型链:

StreamEvent
  -> TurnAction.DISPATCH_EVENT
  -> turnReducer
  -> ActiveTurnState
  -> ActiveTurnStateContext
  -> 流式消息组件

它还采用了两种不同默认值策略:

  • ActiveTurnActionsContext 使用 Actions | null,消费 Hook 在 Provider 缺失时抛错。
  • ActiveTurnStateContext 使用 INITIAL_ACTIVE_TURN_STATE,Provider 缺失时会静默得到初始状态。

后一种写法便于独立渲染,但也可能隐藏 Provider 装配错误。阅读时应先判断这是有意的 fallback,还是应该像 actions 一样 fail fast。

Provider 将 state 与 actions 分开,并把稳定 action 对象保存在 useRef 中。结果是:

  • 只调用动作的组件不会因每个流事件重渲染。
  • 读取完整 ActiveTurnState 的组件仍会跟随每个流事件更新。
  • reducer 负责状态转移,网络读取和回调副作用留在 Hook / service 层。

Context、TanStack Query 与 SSE 的边界

BodySense 的会话历史适合 TanStack Query,因为它是可缓存、可重新获取的服务端快照;当前正在生成的 token、tool call 和交互中断适合 Active Turn reducer,因为它们是高频、顺序敏感的临时状态。

持久会话快照 -> Query cache
本轮流式事件 -> reducer + Context
跨页面认证状态 -> 外部 Store
输入框草稿     -> 本地 state

把流式 token 每次都写进 Query cache 并非绝对错误,但会把事件折叠、缓存失效和 UI 临时状态混在一起,需要更强的理由。

常见误区

  • 用 Context 扛所有“全局”状态。
  • 为避免 null 伪造一个不可用的业务默认对象。
  • Provider 每次渲染都创建新的大对象,却只在消费者端堆 memo
  • 把服务器状态、流式状态和表单草稿混在一起。
  • 认为拆 Context 会让读取 state 的组件自动只订阅某个字段。

从阅读到独立设计

  1. 预测:如果把 Active Turn 的 state 和 actions 合并为一个对象,哪些只使用动作的组件会获得额外重渲染?
  2. 模仿:为 pendingInteraction 单独设计只读 Context,并说明是否真的值得拆。
  3. 重建:不看源码,实现一个 SelectionProvider,要求 actions 消费者不因 selection 变化而重渲染。
  4. 迁移:给“用户资料”“当前输入草稿”“咨询历史”“当前 token”分别选择本地 state、Context、Query 或 Store,并解释所有权。

面试表达

Context 是共享通道,不是完整状态管理终点。完整判断还要回答状态由谁拥有、怎样演化、更新频率如何、是否来自服务端,以及消费者需要多细的订阅粒度。

创建于 2026/3/19 更新于 2026/8/1