前端知库

useReducer 什么时候比 useState 更合适?

一个组件里有多个事件用相似的方式改同一份状态、或者下一个状态要根据多个字段一起算时,把更新逻辑集中到 reducer(state, action) 里,事件处理器只 dispatch 一个描述“发生了什么”的动作,代码更好读、好调试,reducer 还能单独测试。状态简单时 useState 代码更少,两者可以互相转换,也能在同一组件里混用。

参考答案

简答

比如一个购物车,加商品、删商品、改数量、用优惠券,每个按钮的事件处理器里都在拷贝数组、找对应项、算新值,改状态的代码散落在好几处。useReducer 就是把这些改法集中到一个函数里:reducer(state, action) 接收当前状态和一个动作,返回新状态;事件处理器只写 dispatch({ type: 'added', id }),说明发生了什么。

它适合的情况是:多个事件用相似的方式改同一份状态,或者一个操作要同时改好几个字段。这时用 useState 会让组件里塞满更新代码,useReducer 把“怎么改”和“发生了什么”分开,组件读起来清楚。

好处还有两个。出了 bug,可以在 reducer 里打日志,看到每次状态变化是哪个动作引起的;reducer 是不依赖组件的纯函数,可以单独导出测试。

但它不是越多越好。状态简单时 useState 代码更少,useReducer 要多写 reducer 和动作。react.dev 的建议是:某个组件经常因为状态更新写错而出 bug、想给它更多结构时再用,两者可以随时互相转换,也能在同一组件里混用。另外 reducer 必须是纯函数,不能发请求、设定时器,也不能直接修改原来的状态。

把购物车的更新逻辑写成 reducer

下面的 cartReducer 是一个普通函数,不依赖 React。示例用数组的 reduce 依次执行一串动作,模拟用户的一系列操作,看最后的状态。

JavaScript
function cartReducer(state, action) {
  switch (action.type) {
    case 'added': {
      const exists = state.items.find((item) => item.id === action.id);
      const items = exists
        ? state.items.map((item) =>
            item.id === action.id ? { ...item, qty: item.qty + 1 } : item,
          )
        : [...state.items, { id: action.id, price: action.price, qty: 1 }];
      return { ...state, items };
    }
    case 'removed':
      return { ...state, items: state.items.filter((item) => item.id !== action.id) };
    case 'coupon_applied':
      return { ...state, discount: action.discount };
    default:
      throw new Error('未知动作:' + action.type);
  }
}

const initial = { items: [], discount: 0 };
const actions = [
  { type: 'added', id: 'apple', price: 5 },
  { type: 'added', id: 'apple', price: 5 },
  { type: 'added', id: 'pear', price: 3 },
  { type: 'coupon_applied', discount: 2 },
  { type: 'removed', id: 'pear' },
];

const final = actions.reduce(cartReducer, initial);
console.log(JSON.stringify(final.items)); // [{"id":"apple","price":5,"qty":2}]
console.log(final.discount); // 2
console.log(initial.items.length); // 0

苹果加了两次,所以 qty 是 2;梨加进去又删掉了;优惠券单独记在 discount 里。最后一行打印 0,说明最初的 initial 没有被改过,每个分支都返回了新对象。

为什么一定要返回新对象?因为 React 用 Object.is 比较新旧状态,如果直接改 state.items.push(...) 再返回原来的 state,引用没变,React 会认为状态没变而跳过渲染。所以分支里都用展开语法创建新对象,并且带上 ...state,否则其他字段会丢失。

为什么可以直接用 reduce 测?这也是 reducer 的好处:它只根据输入算输出,不依赖组件,所以 react.dev 说可以把它单独导出来测试。useReducer 这个名字也来自数组的 reduce,都是“拿当前结果和下一项算出新结果”。

在组件里使用

组件里用 useReducer 接上这个 reducer,事件处理器只负责 dispatch:

JSX
import { useReducer } from 'react';

function Cart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [], discount: 0 });

  return (
    <>
      <button onClick={() => dispatch({ type: 'added', id: 'apple', price: 5 })}>加苹果</button>
      <button onClick={() => dispatch({ type: 'coupon_applied', discount: 2 })}>用优惠券</button>
      <p>共 {cart.items.length} 种商品</p>
    </>
  );
}

dispatch 和 setState 一样,只更新下一次渲染的状态,调用以后在同一个事件里读 cart 还是旧值。

动作应该怎么设计?react.dev 建议每个动作描述一次用户操作,即使它会改好几处数据。比如表单有五个字段,点“重置”时应该 dispatch 一个 reset_form,而不是五个 set_field。这样把动作日志打出来,就能看出用户按什么顺序做了什么。

reducer 里不能做什么

reducer 在渲染时运行,所以它必须是纯函数:同样的输入总得到同样的输出,不能发请求、设定时器,也不能修改传进来的对象。严格模式下 React 会在开发环境把 reducer 调用两次,帮你发现这类问题,比如在 reducer 里 push 数组,待办就会被加两次。

请求这类副作用放在事件处理器或 Effect 里,结果回来以后再 dispatch 一个动作。

常见追问

初始状态要通过计算得到,怎样避免每次渲染都重新计算?

把计算函数作为第三个参数传进去:useReducer(reducer, username, createInitialState)。这样 React 只在初始化时调用一次 createInitialState(username)。如果写成 useReducer(reducer, createInitialState(username)),虽然结果只用一次,函数却每次渲染都会执行。

为什么 dispatch 可以不写进 Effect 的依赖?

因为 dispatch 的引用是稳定的,每次渲染都是同一个函数,写不写进依赖都不会让 Effect 多执行。所以把它通过 Context 传给深层组件也很方便,见《Context 与 useReducer 适合管理什么状态?》。

每个分支都写展开语法太啰嗦,有办法简化吗?

可以用 Immer。react.dev 介绍了 use-immer 库的 useImmerReducer,reducer 拿到的是一个草稿对象,可以直接 push 或赋值,Immer 在背后生成新的状态,所以也不用 return。