讲解
当状态更新逻辑变复杂——一个动作要联动改多个字段、下一个状态依赖当前状态的多个部分、更新方式有十几种——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。