useReducer 与复杂状态
当一个组件里出现五六个 useState,而且它们经常需要一起更新时,代码会变成这样:
function handleSubmitSuccess(data: Data) {
setLoading(false);
setError(null);
setData(data);
setLastFetch(Date.now());
setRetryCount(0);
}五个 setter 必须一起调用,漏一个就产生不一致状态。useReducer 就是为这种情况准备的:把「状态如何变化」的逻辑集中到一个纯函数里。
1. reducer 是什么
1.1 一个纯函数
(当前状态, 动作) => 新状态就这么简单。它是纯函数:不发请求、不改 DOM、不读随机数、不改传入的 state,只根据输入算出新状态。
type State = { count: number; step: number };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "setStep"; step: number }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + state.step };
case "decrement":
return { ...state, count: state.count - state.step };
case "setStep":
return { ...state, step: action.step };
case "reset":
return { count: 0, step: 1 };
}
}1.2 在组件里用
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<input
type="number"
value={state.step}
onChange={(e) => dispatch({ type: "setStep", step: Number(e.target.value) })}
/>
</div>
);
}组件里只剩「派发意图」,不含任何状态计算逻辑。
好的 action 名:addedTodo、submitFailed、stepChanged——它们描述用户或系统做了什么。
坏的 action 名:setCount、updateState——这只是把 setter 换了个写法,reducer 退化成一个大型 setState,失去了集中逻辑的意义。
这个命名习惯直接决定了 reducer 能不能读懂。
1.3 用 TypeScript 保证穷尽
联合类型的 action 配合 switch,能让 TS 帮你检查是否漏了分支:
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1 };
default: {
// 如果 Action 联合里还有没处理的分支,这里会编译报错
const never: never = action;
throw new Error("未处理的 action: " + JSON.stringify(never));
}
}
}新增一种 action 时,忘记处理会立刻在编译期暴露。这是 reducer 相比一堆 setState 的一个隐性优势。
2. 手写一个 store
useReducer 的内核,加上订阅机制,就是 Redux。下面这段纯 TypeScript 把两者一起实现了,跑一遍就能理解「状态容器」这四个字。
interface Todo {
id: number;
title: string;
done: boolean;
}
type Filter = "all" | "active" | "done";
interface State {
todos: Todo[];
filter: Filter;
nextId: number;
}
type Action =
| { type: "added"; title: string }
| { type: "toggled"; id: number }
| { type: "filterChanged"; filter: Filter }
| { type: "clearedDone" };
// 纯函数:给定 state 和 action,算出新 state
function reducer(state: State, action: Action): State {
switch (action.type) {
case "added":
return {
todos: state.todos.concat([
{ id: state.nextId, title: action.title, done: false },
]),
filter: state.filter,
nextId: state.nextId + 1,
};
case "toggled":
return {
todos: state.todos.map(function (t) {
if (t.id !== action.id) return t;
return { id: t.id, title: t.title, done: !t.done };
}),
filter: state.filter,
nextId: state.nextId,
};
case "filterChanged":
return { todos: state.todos, filter: action.filter, nextId: state.nextId };
case "clearedDone":
return {
todos: state.todos.filter(function (t) {
return !t.done;
}),
filter: state.filter,
nextId: state.nextId,
};
default: {
const never: never = action;
throw new Error("未处理: " + JSON.stringify(never));
}
}
}
// 状态容器 = reducer + 当前状态 + 订阅列表
interface Store<S, A> {
getState: () => S;
dispatch: (action: A) => void;
subscribe: (fn: (s: S) => void) => () => void;
}
function createStore<S, A>(
reduce: (s: S, a: A) => S,
initial: S
): Store<S, A> {
let state = initial;
const listeners: Array<(s: S) => void> = [];
return {
getState: function () {
return state;
},
dispatch: function (action: A) {
const next = reduce(state, action);
if (Object.is(next, state)) return; // 状态没变就不通知(React 的 bailout)
state = next;
for (const fn of listeners) fn(state);
},
subscribe: function (fn: (s: S) => void) {
listeners.push(fn);
return function () {
const i = listeners.indexOf(fn);
if (i >= 0) listeners.splice(i, 1);
};
},
};
}
// 派生数据:从 state 算出来,不单独存
function visibleTodos(state: State): Todo[] {
if (state.filter === "active") {
return state.todos.filter(function (t) {
return !t.done;
});
}
if (state.filter === "done") {
return state.todos.filter(function (t) {
return t.done;
});
}
return state.todos;
}
const store = createStore<State, Action>(reducer, {
todos: [],
filter: "all",
nextId: 1,
});
// 模拟一个组件订阅 store
const unsubscribe = store.subscribe(function (s) {
const titles = visibleTodos(s).map(function (t) {
return (t.done ? "[x] " : "[ ] ") + t.title;
});
console.log(" render -> filter=" + s.filter + " | " + titles.join(" , "));
});
console.log('dispatch added "写 reducer"');
store.dispatch({ type: "added", title: "写 reducer" });
console.log('dispatch added "写测试"');
store.dispatch({ type: "added", title: "写测试" });
console.log("dispatch toggled #1");
store.dispatch({ type: "toggled", id: 1 });
console.log("dispatch filterChanged active");
store.dispatch({ type: "filterChanged", filter: "active" });
console.log("dispatch clearedDone");
store.dispatch({ type: "clearedDone" });
unsubscribe();
console.log("已退订,再 dispatch 不会有 render 输出");
store.dispatch({ type: "added", title: "不会显示" });
console.log("最终 todo 数量: " + store.getState().todos.length);这 60 行代码里包含了状态管理的全部核心概念:纯函数 reducer、单一状态源、dispatch 派发、订阅通知、派生数据现算、引用相等即跳过。第 15 章讲的所有状态库,无非是在这个骨架上加了选择性订阅、中间件和开发者工具。
3. useState 还是 useReducer
3.1 判断标准
| 情况 | 选择 |
|---|---|
| 单个独立的值(开关、输入框、计数) | useState |
| 两三个不相关的值 | useState |
| 多个值必须同步更新 | useReducer |
| 下一个状态依赖前一个状态的复杂逻辑 | useReducer |
| 状态更新逻辑分散在很多事件处理函数里 | useReducer |
| 需要给深层子组件传「更新能力」 | useReducer(传 dispatch 即可) |
| 需要为状态逻辑写单元测试 | useReducer(reducer 是纯函数,好测) |
| 需要撤销/重做、日志、时间旅行 | useReducer |
3.2 传 dispatch 比传一堆回调好
// useState 版本:要传五个回调下去
<TodoItem todo={t} onToggle={toggle} onEdit={edit} onDelete={del} onMove={move} />
// useReducer 版本:只传 dispatch
<TodoItem todo={t} dispatch={dispatch} />而且 dispatch 的引用永远不变(React 保证),所以它不需要 useCallback,放进依赖数组也永远不会导致重新执行。这在配合 React.memo 时是很大的优势。
3.3 两者可以混用
不需要非此即彼。常见的做法是:核心业务状态用 reducer,UI 局部状态(某个下拉是否展开)用 useState。
4. 用状态机消除非法状态
4.1 布尔标记的组合爆炸
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [data, setData] = useState<Data | null>(null);四个变量能组合出 16 种状态,但只有 4 种是合法的。剩下 12 种(比如 loading 和 error 同时为 true、success 为 true 但 data 是 null)都是 bug 的温床,而且总有一天会出现。
4.2 用可辨识联合建模
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Data }
| { status: "error"; message: string };
type Action =
| { type: "fetch" }
| { type: "resolved"; data: Data }
| { type: "rejected"; message: string }
| { type: "retry" };
function reducer(state: State, action: Action): State {
switch (state.status) {
case "idle":
return action.type === "fetch" ? { status: "loading" } : state;
case "loading":
if (action.type === "resolved") return { status: "success", data: action.data };
if (action.type === "rejected") return { status: "error", message: action.message };
return state;
case "success":
return action.type === "fetch" ? { status: "loading" } : state;
case "error":
return action.type === "retry" ? { status: "loading" } : state;
}
}注意这个 reducer 是先 switch 状态、再判断动作——这就是状态机的写法。它天然表达了「在某个状态下,只有某些动作是有意义的」。
比如「已经在 loading 时又收到 fetch」会被忽略,这一行代码就消灭了重复请求的问题。
组件里的消费也变得类型安全:
switch (state.status) {
case "idle":
return <button onClick={() => dispatch({ type: "fetch" })}>加载</button>;
case "loading":
return <Spinner />;
case "error":
return <Error msg={state.message} onRetry={() => dispatch({ type: "retry" })} />;
case "success":
return <View data={state.data} />; // TS 知道 data 一定存在
}不可能再写出「success 状态下 data 为 null」的代码——因为类型系统不允许。
把状态机写法推到极致就是 XState 这类库:显式声明状态、事件、转移规则,还能生成可视化状态图。
对于表单向导、支付流程、播放器这类状态复杂的场景,值得考虑。但大多数情况下,手写一个「先 switch 状态」的 reducer 已经足够。
5. reducer + Context:轻量状态管理
把第 9 章的 Context 拆分模式和 reducer 组合起来,就得到了一套够用的全局状态方案:
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);
export function TodoProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
// dispatch 引用永远不变,所以这个 Provider 的 value 天然稳定
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</DispatchContext.Provider>
);
}
export function useTodos() {
const ctx = useContext(StateContext);
if (!ctx) throw new Error("需要 TodoProvider");
return ctx;
}
export function useTodoDispatch() {
const ctx = useContext(DispatchContext);
if (!ctx) throw new Error("需要 TodoProvider");
return ctx;
}只 dispatch 不读状态的组件(比如「添加」按钮)用 useTodoDispatch(),它永远不会因为列表变化而重渲染。
这套方案的适用范围:中小型应用、状态更新不频繁、不需要选择性订阅。超出这个范围就该上第 15 章的库了。
6. 处理副作用
reducer 必须是纯的,那异步怎么办?答案是:副作用在外面做,做完 dispatch 结果。
async function load(dispatch: React.Dispatch<Action>, id: string) {
dispatch({ type: "fetch" });
try {
const data = await api.get(id);
dispatch({ type: "resolved", data });
} catch (e) {
dispatch({ type: "rejected", message: String(e) });
}
}
// 组件里
<button onClick={() => load(dispatch, id)}>加载</button>这个函数在 Redux 生态里叫 thunk。它不需要任何框架支持——本质就是一个接收 dispatch 的普通异步函数。
不要在 reducer 里发请求、写 localStorage、生成随机 id 或读 Date.now()。
原因:React 在 StrictMode 下会故意调用 reducer 两次来检测纯度;未来的并发特性也可能重放 action。有副作用的 reducer 会产生重复请求、重复写入这类难以复现的 bug。
随机 id 和时间戳应该在 action 里传进来:dispatch({ type: "added", id: crypto.randomUUID(), at: Date.now() })。
一个分页表格组件有这些 state:page、pageSize、sortKey、sortOrder、filters、selectedIds。
已知的联动规则:改变 pageSize、sortKey 或 filters 时,page 要重置为 1;改变 filters 时,selectedIds 要清空。
用 useState 实现需要在多个 handler 里重复这些规则。请设计 Action 类型并写出 reducer。
一个文件上传组件有这些状态:未选择、已选择待上传、上传中(有进度)、上传成功(有 url)、上传失败(有原因)、已取消。
用可辨识联合定义 State 和 Action,写出 reducer。要求:上传中才能取消,失败后才能重试,成功后只能重新选择文件。
给本章 Playground 的 store 加上撤销/重做:
- 维护
past、present、future三段 - 增加
undo和redo两个 action - 只有会改变数据的 action 才入栈(
filterChanged不应该被撤销)
实现后验证:添加两项、切换 filter、撤销一次,得到的应该是只有一项且 filter 保持不变。
给 Playground 的 createStore 加一个日志中间件:每次 dispatch 时打印 action 类型、变更前后的 todo 数量。
实现方式:包装原来的 dispatch 函数。想一想这个模式和 Express 的中间件、Nest 的拦截器有什么共同点。
小结
- reducer 是
(state, action) => newState的纯函数,把状态变化逻辑集中到一处 - action 应该描述「发生了什么」而不是「怎么改」
- 用联合类型 +
never兜底,让 TS 检查分支穷尽性 - 选 useReducer 的信号:多值同步更新、逻辑分散、需要测试、需要传更新能力给深层子组件
dispatch引用永远不变,不需要 useCallback,也不会破坏 memo- 用可辨识联合建模状态机能从类型层面消灭非法状态组合
- 「先 switch 状态、再判断动作」的写法自然表达了状态转移规则
- reducer + 拆分的 Context = 轻量全局状态方案,适合中小型应用
- 副作用放在外部函数(thunk)里,做完再 dispatch 结果;reducer 内部绝不能有副作用
- 下一章讲组件通信与状态提升 →