React中的Context与状态管理边界
解释 React 中状态所有权、Context 传递、Reducer 演化、服务端缓存与外部 Store 的职责边界。
[!info] related notes
- 所属 MOC:React MOC
- 相关概念:useContext、useState、useReducer
- 状态选型:React 状态管理方案选型
- 服务端状态:TanStack Query 服务端状态
React中的Context与状态管理边界
为什么这些概念必须一起理解
Context 解决的是“子树中的消费者如何拿到一个值”,不负责决定状态由谁拥有、如何转移、怎样缓存或如何调试。把传递、演化和远程同步混成一个问题,往往会得到一个频繁变化的大 Context。
先判断数据属于哪一类
| 数据 | 默认归属 | 典型工具 |
|---|---|---|
| 单个组件的输入、开关、草稿 | 最近的共同组件 | useState |
| 多分支但局部的状态转移 | 最近的共同组件 | useReducer |
| 子树共享的配置或模块能力 | Provider 所在边界 | Context |
| 可缓存、可失效、可重取的服务端快照 | Query cache | TanStack Query |
| 高频跨页面客户端业务状态 | 外部 Store | Zustand / 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 的组件自动只订阅某个字段。
从阅读到独立设计
- 预测:如果把 Active Turn 的 state 和 actions 合并为一个对象,哪些只使用动作的组件会获得额外重渲染?
- 模仿:为
pendingInteraction单独设计只读 Context,并说明是否真的值得拆。 - 重建:不看源码,实现一个
SelectionProvider,要求 actions 消费者不因 selection 变化而重渲染。 - 迁移:给“用户资料”“当前输入草稿”“咨询历史”“当前 token”分别选择本地 state、Context、Query 或 Store,并解释所有权。
面试表达
Context 是共享通道,不是完整状态管理终点。完整判断还要回答状态由谁拥有、怎样演化、更新频率如何、是否来自服务端,以及消费者需要多细的订阅粒度。