useReducer 是一个 React Hook,它像 useState 一样保存 state,但把所有修改 state 的方式都集中到一个函数里,也就是 reducer。组件用 { type: 'increment' } 这样的 action 对象调用 dispatch,React 把当前的 state 和这个 action 传给 reducer,由 reducer 返回下一个 state。
按钮并不说明数值怎么变,只说明发生了什么。所有的计算都在 reducer 里。加一个返回 { count: state.count * 2 } 的 case 'double',再加一个派发它的按钮,看看一种新的更新是如何融入进来的。
语法
const [state, dispatch] = useReducer(reducer, initialArg, init); // init is optional
function reducer(state, action) {
// return the next state
}
reducer是一个(state, action) => nextState形式的函数。把它定义在组件外部;它不需要组件作用域中的任何东西。initialArg是初始 state,只在第一次渲染时使用。init是可选的。如果传入它,初始 state 就变成init(initialArg)。state是本次渲染的当前 state。dispatch(action)把一个 action 发送给 reducer,并安排一次使用新结果的渲染。它是稳定的:每次渲染都是同一个函数,所以可以放心地向下传递,或者写进依赖数组。
action 可以是任何值,但按照惯例,它是一个对象,带有一个描述发生了什么的 type 字符串,以及 reducer 需要的其他数据:{ type: 'added', text: 'Buy milk' }。
编写 reducer
大多数 reducer 都是对 action.type 进行 switch,每种事件对应一个 case。每个 case 都返回一个全新的 state,从不修改旧的 state。default 分支会抛出错误,这样 dispatch({ type: 'incremnet' }) 这样的拼写错误会明确报错,而不是悄无声息地什么都不做。
根据用户做了什么来给 action 命名(added、toggled、deleted),而不是根据你心里想的 state 变化来命名(setTodos)。这样组件读起来就像一串事件,而 reducer 是唯一决定每个事件含义的地方。
reducer 必须是纯函数
reducer 是纯函数:给定相同的 state 和 action,它返回相同的结果,并且不做任何其他事情。这意味着在 reducer 内部不能修改数据、不能发送请求、不能设置定时器,也不能调用 Math.random() 或 Date.now()。React 依赖这一点。在开发环境的 StrictMode 下,React 会对每个 action 调用两次你的 reducer 并只保留一个结果,帮你发现不纯的 reducer。这里的预览按生产构建的方式运行,所以本页的示例只调用一次。
最常被违反的是“不能修改”这条规则。向 state.todos 中 push 再返回 state,返回的是同一个对象,于是 React 认为没有变化,跳过了渲染,这和更新数组和对象中讲的陷阱一样。请用展开语法、map 和 filter 构建新的数组和对象。
// Wrong: mutates and returns the same object
case 'added':
state.todos.push(action.todo);
return state;
// Right: returns a new object with a new array
case 'added':
return { ...state, todos: [...state.todos, action.todo] };
属于某个事件的副作用,比如保存到服务器,应该放在事件处理函数中,和 dispatch 写在一起。属于 state 本身的副作用,比如把它同步到 localStorage,应该放在 Effect 中。
用 useReducer 实现待办事项列表
下面的 reducer 做了实际的工作:添加、切换和删除待办事项。reducer 顶部的 console.log 会输出每个 action,调试时用它观察 state 如何变化非常方便。这是 reducer 中通常可以容忍的唯一一种副作用,因为它什么都不改变;上线前把它删掉。
添加一个待办事项,勾选一个复选框,再删除一项,然后看看控制台:每次变化都是一行,写明了 action 及其数据。输入框的文字保存在 useState 中,因为它只属于这个输入框,其他事件都不会碰它。在同一个组件中混用这两个 Hook 是很正常的。
新待办事项的 id 在点击处理函数中生成,并随 action 一起传入,所以 reducer 只需复制它。如果在 reducer 内部写 id: nextId++,它就不纯了:在开发环境的 StrictMode 下,第二次调用会跳过一个 id。
dispatch 不会立即改变 state
dispatch 的工作方式和 useState 的 setter 一样:它安排一次渲染,而当前处理函数中的 state 变量保持旧值。如果想立刻使用新的 state,就自己调用 reducer 计算出来。
多点几次。第一行日志总是慢一步,而第二行与渲染后按钮上显示的数字一致。因为 reducer 是一个普通函数,自己调用它是安全的,这也是保持它纯净的另一个好处。
useState 与 useReducer 对比
两个 Hook 都用来存储 state,用其中一个能写出来的东西,用另一个也能写出来。区别在于更新逻辑放在哪里。
| useState | useReducer | |
|---|---|---|
| 更新逻辑 | 分散在各个事件处理函数中 | 集中在一个 reducer 函数中 |
| 适用于 | 几个相互独立的值 | 被许多事件一起修改的多个字段 |
| 代码量 | 简单 state 时更少 | 一开始更多,事件增多后反而更少 |
| 测试 | 通过组件来测试 | 把 reducer 当作普通函数测试 |
| 调试 | 要找出是哪个处理函数设置的 | 在一个地方记录每个 action |
当你发现同一份 state 在许多处理函数中被更新,当一个事件必须同时修改几份必须保持一致的 state,或者当某个处理函数里大部分都是计算下一个 state 的逻辑时,就该考虑 useReducer。对于一个开关或一个文本框,useState 更短也更清晰。以后再切换也不难:把 setter 换成 dispatch,再把逻辑移进各个 case。
惰性初始化
如果你把初始 state 写成一次函数调用,比如 useReducer(reducer, createInitialState('Ada')),那么这次调用在每次渲染时都会执行,尽管 React 只在第一次使用它的结果。如果构建初始 state 开销很大,或者需要根据某个 prop 推导,就传入第三个参数:一个 init 函数。React 只在第一次渲染时调用一次 init(initialArg)。
在输入框中打字:每次按键组件都会渲染,但 createInitialState 只输出一次日志。现在把调用改成 useReducer(reducer, createInitialState('Ada')),再打字。这个函数每次渲染都会运行,而第一次之后的每次结果都被 React 丢掉了。
要传入函数本身,而不是调用它的结果。useReducer(reducer, 'Ada', createInitialState) 是惰性的;useReducer(reducer, createInitialState('Ada')) 则不是。
搭配 context 使用 useReducer
当一棵很深的组件树都需要这份 state 时,reducer 和 context 是很好的搭配。在顶层把 state 和 dispatch 放进 context,下面的任何组件都能读取 state 或发送 action,而无需逐层传递 props。因为 dispatch 永远不变,只发送 action 的组件可以读取一个单独的 dispatch context,在 state 变化时就不会重新渲染。
import { createContext, useContext, useReducer } from 'react';
const TodosContext = createContext(null);
const TodosDispatchContext = createContext(null);
export function TodosProvider({ children }) {
const [todos, dispatch] = useReducer(todosReducer, []);
return (
<TodosContext value={todos}>
<TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
</TodosContext>
);
}
function AddTodo() {
const dispatch = useContext(TodosDispatchContext);
return <button onClick={() => dispatch({ type: 'added', text: 'New' })}>Add</button>;
}
在 React 19 中,context 对象本身就可以作为 provider 使用(<TodosContext value={...}>);<TodosContext.Provider> 也仍然可用。useContext 一页讲解了 context 如何传递到组件,以及它何时会让组件重新渲染。
常见问题
React 中的 useReducer 是什么?
一个用 action 来描述更新的 state Hook。你调用 const [state, dispatch] = useReducer(reducer, initialState),然后在事件处理函数中调用 dispatch({ type: 'added' })。React 把当前的 state 和这个 action 传给你的 reducer,它返回的值就成为下一个 state。
什么时候该用 useReducer 而不是 useState?
当多个事件以相互关联的方式更新同一份 state 时,当下一个 state 同时取决于多个字段时,或者当更新逻辑长到你想把它放进组件外部一个可以单独测试的函数时。如果只有一两个相互独立的值,useState 更简单。
为什么 reducer 必须是纯函数?
React 可能对同一个 action 多次调用你的 reducer(开发环境下 StrictMode 会故意这样做),并期望每次得到相同的结果。所以 reducer 不能修改 state、请求数据、设置定时器或读取随机值。这些事情应该放在事件处理函数或 Effect 中。
dispatch 会立即更新 state 吗?
不会。和 useState 的 setter 一样,dispatch 会安排一次渲染。在当前的事件处理函数中,state 仍然是旧值;新的 state 要到下一次渲染时才出现。
useReducer 的第三个参数是什么?
一个可选的 init 函数。传入它后,React 会用 init(initialArg) 计算初始 state,而且只在第一次渲染时计算。当构建初始 state 开销很大,或者需要依赖某个 prop 时,它很有用。