React useReducer

解释 useReducer 如何用纯 reducer、Action 联合和 dispatch 组织复杂状态转移,以及穷尽处理与协议兼容的取舍。

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

[!info] related notes

React useReducer

[!abstract] 学习目标 能把需求写成有限 Action 集合,保持 reducer 纯净,预测一次 dispatch 的状态变化,并根据协议策略选择严格穷尽或显式忽略未知事件。

API 与运行流程

const [state, dispatch] = useReducer(reducer, initialArg, init?);
用户操作 / 流事件
  -> dispatch(action)
  -> React 在下一次渲染计算 reducer(currentState, action)
  -> nextState
  -> Object.is 比较
  -> 必要时重新渲染
  • reducer 必须是纯函数:相同 state 与 action 得到相同 next state。
  • initialArg 是初始输入;昂贵初始化可放到第三个参数 init
  • dispatch 没有返回值,且身份稳定。
  • 调用 dispatch 后当前闭包里的 state 仍是本次渲染的旧快照。
  • Strict Mode 在开发环境可能调用 reducer 和 initializer 两次,用于暴露不纯逻辑。

Action 联合表达允许发生的事

interface State {
  count: number;
}

type Action =
  | { type: 'increment' }
  | { type: 'set'; value: number }
  | { type: 'reset' };

function assertNever(value: never): never {
  throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'set':
      return { count: action.value };
    case 'reset':
      return { count: 0 };
    default:
      return assertNever(action);
  }
}

type 是判别字段。进入 set 分支后,TypeScript 知道 value 存在。新增 Action 但漏改 reducer 时,assertNever 会让编译失败。

为什么 reducer 不能执行副作用

不要在 reducer 中发请求、写 localStorage、调用随机数或依赖当前时间。React 可能重试渲染,Strict Mode 也会重复调用 reducer;副作用会因此重复发生。

常见分工是:

reducer -> 计算 next state,并可返回“效果描述”
Hook / service -> 真正执行请求、日志、导航或回调

BodySense 的 reduceActiveTurnEvent 返回 { state, effects },把父层回调描述与状态计算一起产出,但真正执行 effect 的工作仍留在 reducer 外部。

严格穷尽与向前兼容不是同一个目标

内部 UI Action 通常由同一个代码库控制,适合 never 穷尽检查。网络协议则可能遇到新版本事件,有两种策略:

  1. 严格消费者:列出所有支持或明确忽略的事件,最后 assertNever。新增契约分支时编译器强制审查。
  2. 宽容消费者:保留 default 忽略未知事件,换取向前兼容,但新增事件可能被静默遗漏。

折中办法是把“当前明确忽略的事件”写成 case,再对真正未知值记录指标或警告。不要让一个没有说明的 default: return state 同时冒充穷尽安全与向前兼容。

BodySense 的两层 Reducer

Provider 层 turnReducer

ActiveTurnContext.tsxTurnAction 包含:派发流事件、重置、历史水合、关闭红旗和标记交互已回答。它是应用内部 Action,分支有限,适合严格联合。

领域层 reduceActiveTurnEvent

activeTurnReducer.ts 消费共享 StreamEvent

StreamEvent.type
  -> 生命周期 / 文本 / 工具 / 安全 / 交互分支
  -> 更新 ActiveTurnState
  -> 收集 ActiveTurnEffect
  -> 记录每种事件的最高 seq

这里还包含生产环境才会出现的细节:

  • 通过 lastSeqByType 避免重复事件。
  • 对 tool call 使用 Record<id, T> 做 upsert。
  • message.text.delta 按顺序追加文本。
  • 中断状态下的 stream.done 不能误判为完成。
  • 当前实现对多个 payload 使用类型断言,并用 default 忽略未处理事件;这正是运行时校验和协议穷尽练习的入口。

什么时候比 useState 更合适

  • 一个事件会同时改变多个相关字段。
  • 状态有明确阶段和允许转移。
  • 更新规则需要脱离组件单独测试。
  • 多个入口会触发同一种业务动作。

如果只是输入框、开关或彼此独立的少量值,useState 通常更直接。频繁出现 setState(prev => ({ ...prev })) 只是信号,不是自动迁移规则;关键是是否存在值得命名的状态转移。

常见错误

  • 直接修改 state 后返回原对象,React 可能因 Object.is 跳过更新。
  • reducer 中读取时间、生成随机 ID 或调用服务。
  • Action 写成 { type: string; payload?: any },失去分支约束。
  • dispatch 后立刻读取 state,误以为它已经更新。
  • 用静默 default 掩盖本应穷尽的内部 Action。
  • 每次 reset 都复用包含嵌套可变对象的初始状态常量。

从阅读到独立实现

  1. 预测:连续收到 seq 相同的 message.text.delta 时,BodySense 为什么只应处理一次?
  2. 模仿:为内部 TurnAction 增加“清空错误但保留文本”的动作,并补 reducer 测试。
  3. 重建:不看现有实现,根据四条需求写一个支持 idle/streaming/interrupted/completed 的 reducer。
  4. 调试:定位一个 reducer 直接修改嵌套对象导致测试间相互污染的问题。
  5. 迁移:为网络事件选择严格或宽容策略,并用新增未知事件测试证明行为。
创建于 2026/3/19 更新于 2026/8/1