讲解

当状态更新逻辑变复杂——一个动作要联动改多个字段、下一个状态依赖当前状态的多个部分、更新方式有十几种——useState 会让 setXxx 调用散落各事件回调,逻辑越来越难追。useReducer 把「怎么更新」收敛到一个纯函数里:const [state, dispatch] = useReducer(reducer, initialState),事件回调只负责 dispatch({ type: 'added', payload }),所有更新规则集中在 reducer 里。

reducer 的签名是 (state, action) => newState,必须是纯函数:同样的输入同样的输出,不请求、不改外部变量、不就地改 state(不可变规则同前)。这个约束换来的是可测试性(纯函数直接断言)和可预测性(所有状态迁移都能在 reducer 里一眼看全,配合 console.log(action) 就是现成的状态机日志)。

什么时候从 useState 升级到 useReducer?信号:多个 setState 总是成对出现;状态迁移有明确的名字(添加/删除/勾选/重置);需要撤销重做;状态更新逻辑要脱离组件单独测试。todo 列表、多步表单、编辑器是典型场景。反过来,简单开关、单个输入值用 useState 就好——reducer 是复杂度管理工具,简单状态用它反而绕。

dispatch 有个实用特性:它的引用永远稳定,可以安全地传给深层组件或放进 effect 依赖,不会像内联函数那样每次渲染都变。

示例

手写一个 todo reducer 并验证所有状态迁移——纯函数测试就这么简单(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

function todosReducer(state, action) {
  switch (action.type) {
    case 'added':
      return [...state, { id: action.id, text: action.text, done: false }];
    case 'toggled':
      return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case 'deleted':
      return state.filter((t) => t.id !== action.id);
    case 'cleared-done':
      return state.filter((t) => !t.done);
    default:
      throw new Error('未知 action: ' + action.type); // 未知动作直接报错,尽早暴露
  }
}

let state = [];
state = todosReducer(state, { type: 'added', id: 1, text: '写周报' });
state = todosReducer(state, { type: 'added', id: 2, text: '改 bug' });
assert.strictEqual(state.length, 2);
assert.strictEqual(state[0].done, false);

state = todosReducer(state, { type: 'toggled', id: 1 });
assert.strictEqual(state[0].done, true);
assert.strictEqual(state[1].done, false); // 只动目标项

state = todosReducer(state, { type: 'cleared-done' });
assert.deepStrictEqual(
  state.map((t) => t.text),
  ['改 bug'],
);

assert.throws(() => todosReducer(state, { type: 'noop' }), /未知 action/);

// 纯函数福利:同一序列重放结果必然一致(时间旅行/撤销的基础)
let replay = [];
for (const a of [
  { type: 'added', id: 1, text: '写周报' },
  { type: 'added', id: 2, text: '改 bug' },
  { type: 'toggled', id: 1 },
  { type: 'cleared-done' },
]) {
  replay = todosReducer(replay, a);
}
assert.deepStrictEqual(replay, state);

console.log('最终状态:', JSON.stringify(state));
console.log('action 序列重放结果一致 —— 纯函数的可预测性');

组件中的用法:

import { useReducer } from 'react';

let nextId = 3;

function TodoApp() {
  const [todos, dispatch] = useReducer(todosReducer, [
    { id: 1, text: '写周报', done: false },
    { id: 2, text: '改 bug', done: true },
  ]);

  return (
    <>
      <button onClick={() => dispatch({ type: 'added', id: nextId++, text: '新任务' })}>添加</button>
      <button onClick={() => dispatch({ type: 'cleared-done' })}>清除已完成</button>
      <ul>
        {todos.map((t) => (
          <li key={t.id} onClick={() => dispatch({ type: 'toggled', id: t.id })}>
            {t.done ? '✅' : '⬜'} {t.text}
          </li>
        ))}
      </ul>
    </>
  );
}

常见坑

  • reducer 里就地改 state:纯函数约束不能破,所有分支返回新对象/新数组。
  • reducer 里发请求:副作用属于事件回调或 effect,reducer 只做状态迁移。
  • action 设计成 setState 换皮{ type: 'set', value } 丢失了 reducer 的意义;action 描述「发生了什么」(added/toggled),不描述「怎么改」。
  • default 分支静默返回:未知 action 应该 throw,拼错的 type 才能第一时间暴露。
  • 简单状态硬上 reducer:一个布尔开关用 useState,别为仪式感激增代码。

小结

useReducer 把更新逻辑收敛到纯函数 reducer,事件回调只 dispatch 意图;复杂/联动状态升级它,简单状态别滥用。下一章讲性能优化三件套:memo、useMemo、useCallback。