1. 从useState到useReducer:为什么你需要第二个状态方案
这两年我在跟不少前端开发者交流时发现一个很有意思的现象:很多人写 React 组件,一遇到复杂状态就下意识地堆 useState,结果状态逻辑越写越乱,组件层级越来越深,到最后连自己都理不清状态是怎么变化的。我在实际项目里也踩过这个坑,后来认真把 useReducer 吃透之后,再看那些状态复杂的需求,思路完全不一样了。
先说不绕弯子的结论:useReducer 是 React 提供的一个用于管理复杂状态逻辑的 Hook,它的核心思想是把"状态的更新规则"从组件里抽离出来,集中到一个纯函数里统一管理。你可以把它理解成给组件状态装了一个"统一调度中心"——组件内部不再直接修改状态,而是通过派发动作(dispatch action)去触发状态变化,真正决定状态怎么变的,是那个 reducer 函数。
它主要解决几类问题:
- 多个关联状态字段需要一起更新。比如表单的多个输入框、分页的页码和每页条数、筛选条件和搜索结果,这些状态往往是联动变化的,拆成多个 useState 会导致每个 setState 之间互相依赖,逻辑分散难以维护。
- 状态更新依赖当前状态的前一个值。典型的计数器、购物车数量加减、撤销重做这类场景,如果使用 useState 不小心把 setState 写到异步回调里,很容易拿到旧的 state,而 useReducer 因为 reducer 是纯函数,每次都能拿到最新的状态快照。
- 状态更新有固定的动作类型和流转规则。比如登录状态从"未登录"到"登录中"再到"已登录/登录失败",这类有明确状态机语义的逻辑,用 useReducer 表达会清晰得多,代码的可读性远超一堆分散的布尔值。
- 需要将状态逻辑复用到多个组件。useReducer 可以和 useContext 搭配,实现类似全局状态管理的效果,而且不需要引入第三方库。
适合谁看呢?我觉得有三类人最应该认真学它:一是刚学完 React 基础 Hooks、正处在"会用 useState 但一写复杂页面就头疼"阶段的开发者;二是接手过或写过那种状态逻辑乱成一锅粥的组件、想找到更优雅组织方式的人;三是在技术方案选型时纠结"到底用 Redux 还是 Context + useReducer"的人。这篇文章不是从 API 文档角度去介绍,而是把我实际项目里用 useReducer 的思路、踩过的坑、还有性能分析都摊开来讲,尽量让你看完就能直接上手用。
2. useReducer 的核心心智模型:不是替代 useState,而是换一种管理思路
2.1 reducer 到底是什么?一个做饭的例子
很多初学者看到 reducer 这个词就头疼,觉得是函数式编程里的高级概念。其实没那么玄乎。你可以把 reducer 想象成一个"做饭的师傅":
组件是"顾客",它把点菜单(action)递给师傅;reducer 是"师傅",它根据菜单上写的菜名(type)决定该炒什么菜(新的状态);而炒出来的菜(新 state)端回给顾客。
在这个模型里,有几个关键点:
- 顾客不能自己进厨房翻锅铲。对应到代码里,组件永远不要直接给状态赋值,只能通过 dispatch 传递"想做什么"这个意图。
- 师傅只看菜名做菜。reducer 只根据传入的 action 决定返回什么新状态,它不关心谁点了菜、为什么点菜,也不关心是不是有另一份菜单在同时被处理。
- 同一道菜每次做出来味道一样。这是纯函数(pure function)的核心要求——给定相同的 state 和 action,永远返回相同的结果,不做任何副作用(不请求接口、不写日志、不操作 DOM)。
下面上一个最经典的最小例子:
import { useReducer } from 'react'; function counterReducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; default: return state; } } function Counter() { const [state, dispatch] = useReducer(counterReducer, { count: 0 }); return ( <div> <p>当前计数:{state.count}</p> <button onClick={() => dispatch({ type: 'increment' })}>+1</button> <button onClick={() => dispatch({ type: 'decrement' })}>-1</button> <button onClick={() => dispatch({ type: 'reset' })}>重置</button> </div> ); }这个例子里的counterReducer接收两个参数:当前的 state 和 action。action 是一个普通对象,约定俗成至少有一个type字段,用来告诉 reducer 应该走哪条更新逻辑。reducer 通过switch(也可以用 if-else,但 switch 的语义更清晰)判断 action.type,然后返回一个全新的对象作为新状态。
2.2 useReducer 的完整签名和每个参数的作用
useReducer 的完整签名有三种重载形式:
// 形式一:不传初始值,state 为 undefined const [state, dispatch] = useReducer(reducer); // 形式二:普通初始值 const [state, dispatch] = useReducer(reducer, initialArg); // 形式三:惰性初始化(lazy initialization) const [state, dispatch] = useReducer(reducer, initialArg, init);我把每个参数的作用和注意点整理成一张表:
| 参数 | 作用 | 使用要点 |
|---|---|---|
| reducer | 纯函数,接收 (state, action),返回新 state | 不要在里面做副作用,不要在里面调用 setState 或其他 Hook |
| initialArg | 初始状态的值 | 如果第三个参数 init 存在,这个值会作为 init 的参数传入 |
| init | 可选函数,用来惰性初始化 | 适合需要从 props、localStorage、接口缓存等计算初始状态的场景 |
| dispatch | 派发函数,用来触发状态更新 | dispatch 函数本身是稳定的(stale closure 问题里最关键的优势) |
这里重点说两个细节。
第一个细节是"惰性初始化"。先看代码:
function init(initialCount) { // 这里可以做复杂计算,比如从 localStorage 读取,或者对 props 做转换 return { count: initialCount, timestamp: Date.now() }; } const [state, dispatch] = useReducer(reducer, 5, init);当第三个参数 init 存在时,useReducer 会把第二个参数 initialArg(这里是 5)传给 init,用init(5)的返回值作为初始 state。为什么叫"惰性"?因为 init 函数只会在组件首次渲染时执行一次,之后的重新渲染不会重复调用。如果你的初始状态需要做开销较大的计算(比如解析 JSON、遍历数组、读取缓存),用这种形式可以避免每一次渲染都白算一遍。相比之下,如果直接把计算写在组件体里再传给 useReducer,那每次渲染都会执行一次计算。
第二个细节是 dispatch 函数的稳定性。useReducer 返回的 dispatch 在组件的整个生命周期里是保持不变的(引用不变),这意味着如果你把它传给子组件、useMemo 的依赖数组、或者放进 effect 的依赖里,都不会因为父组件重新渲染而导致子组件不必要的重渲染或 effect 重新执行。这是 useState 的 setState 同样具备的特性,但很多写 useReducer 的人没意识到这点,导致依赖数组里多了一个不稳定的函数引用,引发各种诡异问题。
2.3 和 useState 的对比:什么时候选谁
我想先打破一个常见的误解:useReducer 并不是 useState 的"高级版",两者是并列的状态管理工具,各有适合的场景。
useState 适合:
- 独立、简单的状态字段,比如一个弹窗的开合、一个 loading 开关
- 状态之间没有联动关系
- 更新逻辑简单,不需要集中管理
- 状态数量少,组件本身不复杂
useReducer 适合:
- 多个状态字段之间有联动(比如筛选条件变化后,分页要重置)
- 状态更新依赖当前值,且有多条明确的更新路径
- 状态有固定的"动作类型"集合(适合类型系统约束)
- 希望把状态逻辑抽出来单独测试(reducer 是纯函数,单测极其方便)
我举个实际例子。假如你要写一个搜索页面,状态包括:搜索关键词、当前页码、每页条数、总条数、加载状态、错误信息。这些字段不是独立的——关键词变化时页码要重置为 1,每页条数变化时页码也可能要重置,加载开始和结束要联动修改 loading 字段。如果全用 useState,你会在组件里到处写"啊这里还要把 page 重置",很容易漏掉某一条联动,bug 就出现了。
用 useReducer 的话,这些联动规则都收拢在 reducer 里:
function searchReducer(state, action) { switch (action.type) { case 'SET_KEYWORD': // 关键词一变,重置页码 return { ...state, keyword: action.keyword, page: 1 }; case 'SET_PAGE': return { ...state, page: action.page }; case 'SET_PAGE_SIZE': return { ...state, pageSize: action.pageSize, page: 1 }; case 'FETCH_START': return { ...state, loading: true, error: null }; case 'FETCH_SUCCESS': return { ...state, loading: false, result: action.result, total: action.total }; case 'FETCH_ERROR': return { ...state, loading: false, error: action.error }; default: return state; } }这样一写,所有"关键词变化导致页码重置"之类的联动逻辑,都集中在一个函数里,代码审查者只需要看 reducer 就能理解状态流转的完整规则。相比之下,useState 的版本会把联动规则散落在事件处理函数里,一旦组件膨胀起来,查找成本成倍上升。
不过也要泼一盆冷水:如果你的状态很简单,只有一个布尔值、一个数字,强行 useReducer 反而显得过度设计,代码量多了,可读性未必提升。我的建议是:当你在 useState 版本里发现自己开始"手动同步多个 setState"或者"为了联动写了很多 if 判断"时,才是切换到 useReducer 的最佳时机。
3. 从零写一个"购物车编辑器":完整实战拆解
理论说再多,不如把一个有代表性的项目从头到尾写一遍。我用一个购物车编辑器作为例子,它几乎涵盖了 useReducer 实战里最常见的状态类型和联动场景:
- 商品列表支持增加、减少数量
- 支持按商品 ID 删除
- 支持清空购物车
- 支持批量设置某个分类下所有商品的数量
- 实时计算总价、总件数
- 支持撤销上一次操作
上面的需求如果全用 useState,大概要维护一个商品数组、一个总数变量(但它又应该由商品数组推导出来)、一个撤销栈,逻辑散落各处。而 useReducer 可以把这些全部收敛成一个 reducer,外加一个独立的"撤销栈"逻辑。
3.1 先定义状态结构和动作类型
我习惯先定义状态结构和动作类型,再写 reducer。这里直接用 JavaScript 的createReducer配合常量,如果项目用 TypeScript,更推荐用Discriminated Union联合类型约束每个 action 的 payload(这点后面单独展开)。
// 商品项结构 // { // id: string; // name: string; // price: number; // quantity: number; // category: string; // } const initialState = { items: [ { id: 'p1', name: '机械键盘', price: 399, quantity: 1, category: '外设' }, { id: 'p2', name: '显示器', price: 1299, quantity: 1, category: '外设' }, { id: 'p3', name: '电竞椅', price: 899, quantity: 1, category: '家具' }, ], history: [], // 存放过去的状态快照,用于撤销 future: [], // 存放撤销后可以重做的快照 };动作类型初步设计如下:
const ACTIONS = { INCREASE_ITEM: 'INCREASE_ITEM', DECREASE_ITEM: 'DECREASE_ITEM', REMOVE_ITEM: 'REMOVE_ITEM', CLEAR_CART: 'CLEAR_CART', SET_CATEGORY_QUANTITY: 'SET_CATEGORY_QUANTITY', UNDO: 'UNDO', REDO: 'REDO', };3.2 实现 reducer:每个 case 背后的状态变更规则
下面是我实际写的 reducer 的核心部分。注意几个关键点:每次修改 items 的操作,都要先更新历史栈;items 的更新一律用不可变(immutable)的方式,返回新数组;UNDO和REDO从对应的栈里弹出快照。
function cartReducer(state, action) { switch (action.type) { case ACTIONS.INCREASE_ITEM: { const nextItems = state.items.map(item => item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item ); return pushHistory(state, nextItems); } case ACTIONS.DECREASE_ITEM: { const nextItems = state.items.map(item => item.id === action.payload.id && item.quantity > 1 ? { ...item, quantity: item.quantity - 1 } : item ); return pushHistory(state, nextItems); } case ACTIONS.REMOVE_ITEM: { const nextItems = state.items.filter(item => item.id !== action.payload.id); return pushHistory(state, nextItems); } case ACTIONS.CLEAR_CART: { const nextItems = []; return pushHistory(state, nextItems); } case ACTIONS.SET_CATEGORY_QUANTITY: { const { category, quantity } = action.payload; const nextItems = state.items.map(item => item.category === category ? { ...item, quantity } : item ); return pushHistory(state, nextItems); } case ACTIONS.UNDO: { if (state.history.length === 0) return state; const previous = state.history[state.history.length - 1]; return { ...state, items: previous, history: state.history.slice(0, -1), future: [state.items, ...state.future], }; } case ACTIONS.REDO: { if (state.future.length === 0) return state; const next = state.future[0]; return { ...state, items: next, history: [...state.history, state.items], future: state.future.slice(1), }; } default: return state; } } function pushHistory(state, nextItems) { return { ...state, items: nextItems, history: [...state.history, state.items], future: [], // 一旦产生新操作,清空重做栈 }; }这里有几个我在实践中反复强调的细节:
- 永远不要直接修改 state.items。
map、filter返回新数组;但注意 map 内部如果只改某个 item 的 quantity,也要用{ ...item, quantity: ... }新建对象,否则该 item 的旧引用会进入 history 栈,撤销时可能出现"改了但没完全改"的诡异表现。 - history 栈存的是快照(引用),不深拷贝。因为每次修改都产生新的 items 数组和新的 item 对象,旧的引用天然不可变,存引用既可节省内存,又保证不可变性不被破坏。前提是你在所有 case 里都严格贯彻不可变更新。
- UNDO/REDO 要注意栈的边界条件。栈为空时直接返回原 state,避免 undefined 项混入。
3.3 组件层的事件组织:dispatch 如何传递意图
reducer 写完之后,组件层其实非常薄。每个用户操作只是组织一个 action 对象 dispatch 出去,不直接触碰状态的计算。这是我写的组件部分:
function CartEditor() { const [state, dispatch] = useReducer(cartReducer, initialState); const totalCount = state.items.reduce((sum, item) => sum + item.quantity, 0); const totalPrice = state.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); return ( <div> <div className="toolbar"> <button onClick={() => dispatch({ type: ACTIONS.UNDO })} disabled={state.history.length === 0}> 撤销 </button> <button onClick={() => dispatch({ type: ACTIONS.REDO })} disabled={state.future.length === 0}> 重做 </button> <button onClick={() => dispatch({ type: ACTIONS.CLEAR_CART })}>清空购物车</button> </div> <ul> {state.items.map(item => ( <li key={item.id}> <span>{item.name}</span> <span>单价:{item.price}</span> <button onClick={() => dispatch({ type: ACTIONS.DECREASE_ITEM, payload: { id: item.id } })}> - </button> <span>{item.quantity}</span> <button onClick={() => dispatch({ type: ACTIONS.INCREASE_ITEM, payload: { id: item.id } })}> + </button> <button onClick={() => dispatch({ type: ACTIONS.REMOVE_ITEM, payload: { id: item.id } })}> 删除 </button> </li> ))} </ul> <p>总件数:{totalCount}</p> <p>总价:{totalPrice}</p> </div> ); }这个例子里,组件唯一的"计算"是把总件数和总价通过reduce推导出来。注意我这里用的是"派生状态"(derived state)而不是直接在 reducer 里维护 total 字段——这是刻意为之。总价、总件数完全可以由 items 数组推导出来,如果每次操作都去重新计算 total 并写进 state,不仅增加状态字段的一致性维护成本(可能会出现 items 变了 total 没变的 bug),还会让每个 reducer case 都要记得更新 total,非常容易遗漏。能从现有 state 推导出来的数据,就不要存进 state,这是 React 状态设计的一条铁律。
3.4 这个例子的可扩展方向
购物车编辑器只是一个小例子,但它背后的模式可以扩展出很多实际功能:
- 把 reducer 从组件文件里单独抽出去,放到
reducers/cartReducer.js,配合单测覆盖每个 action,非常爽。我在项目里就用 vitest 对 reducer 写过几十个用例,每个 case 一两个 it,把各种边界条件(空车、数量为 1 再减、重复撤销到底)全部钉死。 - 把 items 初始化改为从接口加载,加载完成后 dispatch 一个
SET_ITEMSaction,就实现了"数据回填 + 历史干净"的效果。 - 如果商品支持优惠券,优惠券逻辑是"叠加计算"还是"互斥计算",天然适合放在 reducer 里处理,因为它的动作类型是明确的一小撮。
4. useReducer 在异步场景下的使用实践:从"在 reducer 里请求"到"请求放在哪"
异步处理是 useReducer 实践里最容易让人纠结的地方。Redux 社区早就验证过:"reducer 必须是纯函数,不能在里面请求接口"。但 useReducer 没有官方配套的中间件,很多人复制 Redux 的习惯,放一个 thunk 又觉得重,不放又不知道怎么处理 loading、success、error 三个状态。
我先给结论:在组件或自定义 Hook 里发起异步请求,然后 dispatch 不同的 action 通知 reducer 当前处于什么阶段(loading / success / error),这是最通用、最贴合 useReducer 心智的方案。
4.1 一个典型的异步请求流程怎么写
以用户登录为例。状态字段包括:status(idle / loading / success / error)、userInfo、errorMessage。用 useReducer 管理:
function authReducer(state, action) { switch (action.type) { case 'LOGIN_START': return { ...state, status: 'loading', error: null }; case 'LOGIN_SUCCESS': return { ...state, status: 'success', user: action.payload }; case 'LOGIN_FAILURE': return { ...state, status: 'error', error: action.payload }; case 'LOGOUT': return { ...state, status: 'idle', user: null }; default: return state; } }组件内部的登录函数:
async function handleLogin(username, password) { dispatch({ type: 'LOGIN_START' }); try { const user = await loginApi(username, password); dispatch({ type: 'LOGIN_SUCCESS', payload: user }); } catch (err) { dispatch({ type: 'LOGIN_FAILURE', payload: err.message }); } }这个写法的好处一眼就能看出来:loading、success、error 三个状态被完整建模了,不会出现"请求进行中用户又点了一次登录"时 loading 状态错乱的问题;组件层看起来就是一个瞬时的 dispatch,没有任何中间状态需要手动 sync。缺点也明显:每次写一个接口调用都要手动写三个 dispatch,模板代码有点多。但我更倾向于"显式比隐式好"——可读性和可调试性胜过一点模板代码。
4.2 要不要自己封装一个简单的 async action helper?
如果你的项目里大量使用 useReducer 处理异步,可以封装一个非常轻量的辅助函数,不用引入第三方库:
function createAsyncDispatcher(type, promiseCreator) { return async function (dispatch, payload) { dispatch({ type: `${type}_START`, payload }); try { const result = await promiseCreator(payload); dispatch({ type: `${type}_SUCCESS`, payload: result }); return result; } catch (err) { dispatch({ type: `${type}_FAILURE`, payload: err.message }); throw err; } }; } // 使用示例 const fetchUserDispatcher = createAsyncDispatcher('FETCH_USER', fetchUserApi); async function loadUser(userId) { try { await fetchUserDispatcher(dispatch, { userId }); } catch (e) { // 已经由 FAILURE action 处理状态,这里可以忽略或做额外处理 } }这种模式本质上是手动实现了一个简化版的 thunk。我测试下来,在中小型项目里完全够用,而且比引入 Redux Toolkit 轻得多。不过要提醒一点:不要把异步逻辑放进 reducer 里。比如在 reducer 的LOGIN_STARTcase 里去调用loginApi,然后等结果回来再 dispatch 另外一个 action——这种写法破坏了 reducer 的纯函数特性,会导致难以追踪的状态变更、重复执行、以及 React StrictMode 下 double-invoke 引发的隐患(StrictMode 在开发环境会故意多次调用 reducer 来检查纯函数性,如果你在里面发请求,会被调用两次,逻辑直接炸掉)。
4.3 竞态条件:一个容易被忽略的坑
异步操作还有一个常见问题是竞态条件(race condition),即用户快速触发多次请求,后发出的请求先返回,导致最终展示的数据是旧请求的结果。useReducer 本身不解决这个问题,需要你在 dispatch 之前做校验,或者在 effect 里用 AbortController 取消过期请求。
我处理过的实际场景是搜索框:用户输入"React",又快速改成"Reducer",网络慢的话,第一次请求可能比第二次后返回。如果直接 dispatchSEARCH_SUCCESS,界面就会显示"Reducer"的关键词却对应"React"的搜索结果,搜索词和结果对不上,用户会觉得很奇怪。
解决方案是在 effect 里做一个简单的"过期标记":
useEffect(() => { let ignore = false; async function fetchResults() { dispatch({ type: 'SEARCH_START' }); const results = await searchApi(keyword); if (!ignore) { dispatch({ type: 'SEARCH_SUCCESS', payload: results }); } } fetchResults(); return () => { ignore = true; }; }, [keyword]);cleanup 函数把ignore置为 true,意味着组件卸载或 keyword 再次变化时,旧请求即使返回了也不会再 dispatch 任何 action。这个模式配合 useReducer 的状态管理非常顺滑——reducer 只管状态怎么变,而"这个请求还该不该更新状态"由调用方控制,职责清晰。
5. useReducer + useContext:打造一个不需要第三方库的轻量全局状态方案
我经常被问到"项目状态共享到底选 Redux 还是 Zustand 还是 Context + useReducer"。我的回答是:如果你的需求主要是"多组件共享某个领域状态"、"状态变更逻辑有明确规则"、"不想为一个小模块引入大而全的全局状态库",那 useReducer + useContext 完全能胜任。
5.1 组合的完整代码模式
以一个"用户偏好设置"为例,涉及到主题模式、字体大小、语言三项设置,多个组件(设置面板、文章页、导航栏)都要读取和修改。
先定义 Context 和 Provider:
import { createContext, useContext, useReducer } from 'react'; const SettingsContext = createContext(null); function settingsReducer(state, action) { switch (action.type) { case 'SET_THEME': return { ...state, theme: action.payload }; case 'SET_FONT_SIZE': return { ...state, fontSize: action.payload }; case 'SET_LANGUAGE': return { ...state, language: action.payload }; case 'RESET': return { ...state, theme: 'light', fontSize: 14, language: 'zh-CN' }; default: return state; } } const initialState = { theme: 'light', fontSize: 14, language: 'zh-CN' }; export function SettingsProvider({ children }) { const [state, dispatch] = useReducer(settingsReducer, initialState); return ( <SettingsContext.Provider value={{ state, dispatch }}> {children} </SettingsContext.Provider> ); }组件内部读取和更新:
function SettingsPanel() { const { state, dispatch } = useContext(SettingsContext); return ( <div> <select value={state.theme} onChange={(e) => dispatch({ type: 'SET_THEME', payload: e.target.value })} > <option value="light">浅色</option> <option value="dark">深色</option> </select> <input type="range" min="12" max="24" value={state.fontSize} onChange={(e) => dispatch({ type: 'SET_FONT_SIZE', payload: Number(e.target.value) })} /> <button onClick={() => dispatch({ type: 'RESET' })}>恢复默认</button> </div> ); }所有需要共享设置的组件都可以通过useContext(SettingsContext)拿到统一的 state 和 dispatch。状态变更完全由 reducer 控制,不会出现"在 A 组件改了 localStorage 但 B 组件不知道"的同步问题。
5.2 性能注意事项:不瞎优化,但也不用摆烂
Context + useReducer 的性能一直是讨论热点。每次 state 更新,Provider 的 value 是一个新对象,所有消费了这个 Context 的组件都会重新渲染。这是 Context 的固有行为,不是 useReducer 的锅。
性能优化的几个实用建议:
- 按领域拆分 Context。不要把所有全局状态塞进一个巨型 Context。比如把"用户信息"和"主题设置"拆成两个 Provider,主题切换时用户信息相关的组件不会受影响。
- 把 Context value 用 useMemo 包裹。这样只有当 state 真正变化时才生成新的 value 对象。
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);因为 dispatch 本身稳定,这个 useMemo 实际等价于[state],能减少一部分无意义的子组件重渲染。
用 selector 模式裁剪 Consumer。如果只有一个组件需要状态里的
theme字段,而另一个组件只需要fontSize,可以自己写一个小的 useSelector 样式封装。不过对于中小型项目,我建议先别过度设计,等真的出现性能瓶颈或者能明显感知页面卡顿再拆。dispatch 函数的稳定性是最大加分项。因为 dispatch 在整个生命周期不变,子组件即使每次都重新渲染,也只有在事件触发时才会拿到新的 action 对象;依赖 dispatch 的 useMemo、useEffect 都不会被频繁重建,这点比直接在 Context 里传一堆回调函数要克制得多。
5.3 什么时候该升级到 Redux 或 Zustand?
我自己会遵守几个判断标准:
- 状态是全局性的、跨多个页面、更新频率高(比如用户实时位置、在线状态、播放列表)
- 需要 DevTools 时间旅行调试、持久化中间件、请求缓存等 Redux 生态能力
- 团队成员对 Redux 的模式更熟悉,维护成本反而更低
- 状态逻辑足够复杂,需要引入实体关系、规范化存储(normalized state)等概念
如果只是上面购物车、设置的规模,useReducer + Context 完全绰绰有余。引入一个全局状态库的成本不只是包体积,还有团队关于"什么时候该 dispatch、什么时候该用 selector、action 怎么命名"的规约成本。能用原生 Hooks 解决的,先用原生 Hooks 解决,这是我在实际项目中反复验证过的稳妥路线。
6. 我在生产环境里踩过的坑与排查方法
useReducer 用起来比 useState 稍微复杂,坑也更多。这一节我把生产环境里真正踩过的几个坑整理成完整的排查链路,分享我当时的排查过程和最终修复方案,希望你能直接避雷。
6.1 坑一:reducer 里不小心做了副作用,导致 StrictMode 下状态多算一次
现象:开发环境下,counter 每次点击 +1,数字却变成 +2。用户一脸懵,我也一脸懵。
排查过程:我先检查是不是 onClick 绑定出了问题,后来注释掉 StrictMode 发现数字恢复正常。这时我意识到 React 在开发环境的 StrictMode 下会故意调用两次 reducer(和两次 render),目的就是把 impure reducer 的问题提前暴露出来。我把counterReducer打开一看,里面竟然写了一个console.log和localStorage.setItem——这俩都是副作用,在 StrictMode 下被执行两次,导致一些外部状态被重复写入,表现就是数字变化异常。
修复方案:把副作用从 reducer 里全部挪走。持久化操作用 useEffect 监听 state 变化统一处理;日志也移到组件层。reducer 只保留纯粹的"state 转换"逻辑。
这个坑我印象极深,因为它不是 error,而是"看起来正常但结果不对"的类型,非常难定位。排查到的核心思路是:只要发现开发环境行为和生产环境不一致,优先怀疑 StrictMode 和 impure reducer 的组合。
6.2 坑二:初始化函数 init 里用了组件的 props,但 props 更新后状态却没有跟着变
现象:某个筛选组件,父组件传入defaultFilterprops,子组件第一次渲染时用它初始化 state。父组件更新 props 后,子组件状态没有重置,筛选条件停留在旧值。
排查过程:一开始我以为 useReducer 的 initialArg 变化会自动触发重新初始化,查了文档才知道initialArg只在首次渲染时用一次,后续 props 变化并不会重置 reducer 状态。很多人会把 reducer 当成响应式的"state = computeFromProps"模型,其实它是一次性初始化 + 事件驱动更新的模型。
修复方案:要看产品预期是什么——如果 props 变化时确实想重置状态,需要在组件里手动处理:
useEffect(() => { dispatch({ type: 'RESET_FROM_PROPS', payload: { defaultFilter: defaultFilterProp } }); }, [defaultFilterProp]);或者直接用key={newKey}强制整个组件重新挂载(但成本较高)。我当时选择了第一种,并且把RESET_FROM_PROPS定义成 reducer 里的一个显式 action,逻辑清晰,以后也好测试。
6.3 坑三:dispatch 传入的 action 对象被误当成"可变对象",多次修改导致 reducer 内部出现怪异分支
现象:我写过一个"批量操作"功能,事件处理里先buildAction()构造一个 action,然后在某个循环里给这个 action 添加额外字段,最后 dispatch 出去。结果 reducer 里对同一 action 走到了完全错误的 case,排查了一整个下午。
排查过程:后来发现我在循环里action.payload.count = ...这种写法,导致同一个 action 对象在 reducer 内部被重复读取时 payload 已经变了,switch 判断的 type 没有变,但分支里的计算全部基于被改过的 payload,结果自然不对。
修复方案:action 对象也应当被视为不可变对象。要修改就新建对象,不要复用。我后来把所有构造 action 的逻辑统一改成显式返回新对象:
dispatch({ type: ACTIONS.BATCH_SET, payload: { ids, quantity: newQuantity }, });并且给自己立了个规矩:dispatch 出去的 action,不要再碰它,也不要试图在事件处理函数里 mutate 它。这条规矩让异步回调里的 action 构造也安全了很多。
6.4 坑四:错误地把"派生状态"存进 reducer,导致状态不一致
现象:购物车例子中,我一开始把totalPrice作为一个独立字段存在 state 里,每次增减商品时都手动计算并更新。后来新增了"批量设置数量"的 action,忘记更新 totalPrice,页面上总数对不上。用户反馈"明明 5 件商品但总价显示的是 4 件的钱"。
排查过程:追踪 state 发现items正确,totalPrice是旧值。这个 bug 的本质是冗余状态(derived state 被重复存储)破坏了单一数据源原则。因为如果每个 action 都要记得更新 totalPrice,迟早有遗漏。
修复方案:把 totalPrice 从 state 中移除,在组件层用useMemo或直接reduce计算:
const totalPrice = useMemo( () => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0), [state.items] );这不仅是"重构",更是一个值得记住的设计原则:能让 React 自动推导的状态,就不要让 reducer 手动维护。
6.5 坑五:撤销功能里用了push而不是concat,导致历史栈状态被污染
现象:实现撤销时我用history.push(state.items)把当前 items 推入历史栈。第一次撤销正常,第二次撤销时发现回到了一个"中间态"而不是"最初态"。
排查过程:问题出在push会原地修改数组,而这个数组正是当前 state.items 的引用。当我在后续操作中基于同一数组修改时,历史栈里存的引用也被改了,历史变成了"可变历史"。
修复方案:一律用不可变操作:
history: [...state.history, state.items]这个坑特别隐蔽,因为 JavaScript 数组的push返回新长度而不是新数组,很容易让人忽略它修改的是原数组。凡是和不可变数据打交道的地方,都要养成用concat/展开运算符 的习惯。
6.6 排查思路小结
我在踩了这些坑之后总结了一套流程,分享给你作为参考:
- 先看 StrictMode 下是否复现;复现则优先怀疑 reducer 不纯
- 再确认 action 载荷是否在 dispatch 前后被意外改动
- 看一下是否有字段违反"单一数据源"原则
- 翻一下是否有类似
push的原地修改操作 - 最后检查初始化逻辑里是否依赖了后续 props 的变化
这套顺序基本覆盖了 useReducer 90% 的常见问题,建议你遇到异常的时候照着走一遍,比打印一堆日志来得快。
7. useReducer 面向 TypeScript 的实战姿势:用 Discriminated Union 约束所有裸奔的 action
如果你的项目用了 TypeScript,useReducer 会变得更香——因为 error 可以从"运行时才发现"提前到"编译时就被拦截"。这是我目前最推荐的一节,也是很多人没用好 useReducer 的原因。
7.1 一个标准的 Discriminated Union action 写法
关键思想:把所有可能的 action 类型定义为一个联合类型,每种 action 通过type字面量区分,同时用 TypeScript 收窄(narrowing)让每个 case 里都能正确推断 payload 的类型。
type UserState = { user: { id: string; name: string } | null; status: 'idle' | 'loading' | 'success' | 'error'; error: string | null; }; type UserAction = | { type: 'FETCH_USER_START' } | { type: 'FETCH_USER_SUCCESS'; payload: { id: string; name: string } } | { type: 'FETCH_USER_FAILURE'; payload: string } | { type: 'LOGOUT' }; function userReducer(state: UserState, action: UserAction): UserState { switch (action.type) { case 'FETCH_USER_START': return { ...state, status: 'loading', error: null }; case 'FETCH_USER_SUCCESS': return { ...state, status: 'success', user: action.payload }; case 'FETCH_USER_FAILURE': return { ...state, status: 'error', error: action.payload }; case 'LOGOUT': return { ...state, user: null, status: 'idle' }; default: // 这里推荐用 exhaustive check,确保所有分支都被覆盖 return state; } }注意这行代码:return state前面的 default。TS 里如果你把所有 action 分支都写完了,default 分支实际上是"不可达"的。你可以用exhaustive技巧让它编译报错:
function assertNever(x: never): never { throw new Error('Unexpected object: ' + x); } function userReducer(state: UserState, action: UserAction): UserState { switch (action.type) { ... default: return assertNever(action); } }这样如果有人给UserAction加了新类型但忘了写 case,TS 会直接报错,而不是等运行时才出问题。这是我强烈推荐的一个小技巧,能帮团队省下大量"漏写分支"的 debug 时间。
7.2 把 reducer 单独抽出来做单元测试的体验
reducer 因为是纯函数,测试体验可以说是 React 状态管理方案里最好的之一。我用 vitest 写过下面这样的测试:
import { describe, it, expect } from 'vitest'; import { userReducer, initialState } from './userReducer'; describe('userReducer', () => { it('should handle FETCH_USER_SUCCESS', () => { const state = userReducer(initialState, { type: 'FETCH_USER_SUCCESS', payload: { id: '1', name: '张三' }, }); expect(state.status).toBe('success'); expect(state.user).toEqual({ id: '1', name: '张三' }); }); it('should handle FETCH_USER_FAILURE', () => { const state = userReducer(initialState, { type: 'FETCH_USER_FAILURE', payload: '网络错误', }); expect(state.status).toBe('error'); expect(state.error).toBe('网络错误'); }); });写这类测试几乎没有成本,因为不需要 mock 任何 DOM、HTTP 或组件。回购车、撤销、重置这些业务规则时,我都是先把 reducer 的测试写好,再写组件,测试先行让重构安全感大大提高。很多前端项目不写测试,不是不想写,而是组件测试成本太高;useReducer 恰好把"逻辑"和"视图"切得很干净,是测试性价比最高的抓手。
7.3 reducer 文件结构怎么组织才不乱
实际项目里,当 action 类型变多,我习惯把 reducer 拆成这样:
features/ cart/ reducer.ts actions.ts types.ts useCart.ts CartEditor.tsx其中:
types.ts放CartState、CartAction等类型定义actions.ts放 action creator 函数,目的是提供类型安全且方便调用的 dispatch 参数reducer.ts放纯函数 + initialStateuseCart.ts是一个自定义 Hook,将 useReducer、派生状态和事件方法都包装起来,组件层只消费useCart()
自定义 Hook 的长相大概是:
function useCart() { const [state, dispatch] = useReducer(cartReducer, undefined, createInitialCart); const totalPrice = useMemo( () => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0), [state.items] ); const addItem = useCallback((item: CartItem) => { dispatch({ type: 'ADD_ITEM', payload: item }); }, []); const removeItem = useCallback((id: string) => { dispatch({ type: 'REMOVE_ITEM', payload: { id } }); }, []); return { state, totalPrice, addItem, removeItem, dispatch }; }这样组件里就不再出现dispatch({ type: ACTIONS.REMOVE_ITEM, payload: { id }})这种细碎代码,而是调removeItem(id),语义更清晰,也方便在 Hook 层统一处理派生状态。这个模式我愿称之为 useReducer 的终极形态:reducer 管状态规则、useMemo 管派生状态、useCallback 管事件方法、组件只管渲染。
8. 写在最后:useReducer 的真实定位与我的使用建议
我在不同规模的项目里试过各种状态方案,最后给 useReducer 的定位是:它是 React 原生状态管理里"规则化"与"轻量"之间的最佳平衡点。
它比 useState 多了一层"action 语义",让状态变化有了名字和规则,代码可读性和可维护性明显提升;它比 Redux 少了中间件生态和全局单例,正因为轻,所以反而更容易被理解和被掌控。它不是万能的,复杂到一定程度该上 Redux/Zustand 还是要上;但作为日常开发的主力状态方案,它非常靠谱。
如果你现在正处在"要不要从 useState 换成 useReducer"的纠结里,我给个简单的判断方法:打开你的组件文件,数一下setState的数量和它们之间的联动关系。超过三个且联动明显,或者你发现自己在事件处理函数里写了"既要改 A 又要重置 B 还要清空 C",那今天就动手试试 useReducer 吧。别担心多出来的模板代码——几乎所有从 useState 切到 useReducer 的人,清理完几个组件后,都会有种"以前怎么没想到"的畅快感。
最后再分享一个小技巧:如果你想让 useReducer 的调试体验更顺滑,可以在 dispatch 外面包一层日志函数,把 action 打出来,配合 React DevTools 的 reducer 状态追踪,线上出 bug 时定位效率会快很多。当然,这层日志只用放在开发环境,生产环境记得去掉。用 useReducer 最大的快乐,从来不是代码少了几行,而是当状态逻辑越来越庞杂时,你依然能一眼看穿它每一步是怎么走到这里的。