← [ BACK_TO_NOTES ]

React useReducer

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

#frame #react #concept #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.tsx 的 TurnAction 包含:派发流事件、重置、历史水合、关闭红旗和标记交互已回答。它是应用内部 Action,分支有限,适合严格联合。

领域层 reduceActiveTurnEvent

activeTurnReducer.ts 消费共享 StreamEvent:

StreamEvent.type
  -> 生命周期 / 文本 / 工具 / 安全 / 交互分支
  -> 更新 ActiveTurnState
  -> 收集 ActiveTurnEffect
  -> 按 public run identity/sequence 做幂等守卫

这里还包含 production streaming 才会出现的细节:

  • 通过 (run_id, seq) 语义避免重复/迟到事件;当前实现维护 sequenceRunId + lastSeq,只在同一个 run 内比较 seq。
  • 新的 resumed public Run 拥有新的 seq namespace,即使上一个 Run 已到 seq=99,新 Run 的 seq=1 仍应接受。
  • 对 tool call 使用 Record<id, T> 做 upsert。
  • message.text.delta 按顺序追加文本。
  • 中断状态下的 stream.done 不能误判为业务完成。
  • 当前实现对部分 payload 仍使用类型断言,并用明确的协议/运行时校验边界保证进入 reducer 前的事件可信。

因此不要把“只记一个全局 lastSeq”当成通用流式去重方案;seq 必须先明确自己的 namespace。

什么时候比 useState 更合适

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

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

常见错误

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

从阅读到独立实现

  1. 预测:当前已处理 (R1, 99),随后收到 (R2, 1),为什么不能因为 1 <= 99 就丢弃?
  2. 模仿:为内部 TurnAction 增加“清空错误但保留文本”的动作,并补 reducer 测试。
  3. 重建:不看现有实现,根据四条需求写一个支持 idle/streaming/interrupted/completed/cancelled 的 reducer。
  4. 调试:定位一个 reducer 直接修改嵌套对象导致测试间相互污染的问题。
  5. 迁移:为网络事件选择严格或宽容策略,并用新增未知事件测试证明行为。
创建于 2026/3/19 更新于 2026/9/16
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本