用 React Context + useReducer 实现轻量全局状态:不装 Redux 也能管好状态
不是每个项目都需要 Redux。用户登录信息、主题切换、购物车、一个多步表单的数据——这些跨组件共享的状态,用 React 自带的Context+useReducer就能管得很干净,还不用引第三方库。
但很多人第一次这么写会踩两个坑:要么把逻辑写得散乱、组件里到处dispatch;要么 Context 一变全树重渲染,性能崩了。这篇从「为什么要这么组合」讲起,给出一套可直接抄进项目的写法,并解决那个最要命的重渲染问题。
先说清楚:Context 和 useReducer 各管什么
这俩解决的是不同的问题,组合起来才完整:
useReducer管「状态怎么变」——把所有更新逻辑收拢到一个 reducer 函数里,组件只负责dispatch一个动作,不关心细节。Context管「状态给谁用」——让深层嵌套的组件不用一层层传 props(避免 prop drilling)就能拿到状态和 dispatch。
单用 Context 传一个useState也行,但状态更新逻辑一复杂就乱;useReducer正好补上这块。
从一个真实场景开始:购物车
先看没有全局状态时的痛点。购物车状态在顶层,加购按钮在商品卡片里,数量显示在导航栏里——三个组件隔着好几层。用 props 传,得把cart和setCart从顶层一路透传下去,中间那些根本不关心购物车的组件被迫当二传手。
我们用 Context + useReducer 重构。第一步,定义 reducer,把所有购物车操作集中起来:
// cartReducer.js export const initialState = { items: [] }; export function cartReducer(state, action) { switch (action.type) { case 'ADD': { const exist = state.items.find(i => i.id === action.item.id); if (exist) { // 已存在则数量 +1,而不是重复添加 return { items: state.items.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i ), }; } return { items: [...state.items, { ...action.item, qty: 1 }] }; } case 'REMOVE': return { items: state.items.filter(i => i.id !== action.id) }; case 'CLEAR': return { items: [] }; default: // 兜底:遇到未知 action 直接返回原状态,别静默改错 return state; } }reducer 是纯函数,输入旧状态和 action,输出新状态。所有「加购要不要合并、删除怎么删」的逻辑都在这里,组件里看不到这些细节。
第二步:用 Context 把状态和 dispatch 提供出去
// CartContext.jsx import { createContext, useContext, useReducer } from 'react'; import { cartReducer, initialState } from './cartReducer'; const CartContext = createContext(null); export function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, initialState); return ( <CartContext.Provider value={{ state, dispatch }}> {children} </CartContext.Provider> ); } // 封装一个自定义 Hook,组件用起来更顺,还能顺手做「忘了包 Provider」的校验 export function useCart() { const ctx = useContext(CartContext); if (!ctx) { throw new Error('useCart 必须在 <CartProvider> 内部使用'); } return ctx; }把useContext包成useCart自定义 Hook 是个好习惯:组件里const { state, dispatch } = useCart()一行搞定,而且忘了套 Provider 时会立刻抛出清晰的错误,而不是拿到null后在别处报一个莫名其妙的错。
第三步:在组件里消费
顶层套上 Provider:
// App.jsx import { CartProvider } from './CartContext'; export default function App() { return ( <CartProvider> <Navbar /> <ProductList /> </CartProvider> ); }商品卡片里加购,导航栏里显示数量——都不需要透传 props,各自useCart即可:
// ProductCard.jsx import { useCart } from './CartContext'; function ProductCard({ product }) { const { dispatch } = useCart(); return ( <button onClick={() => dispatch({ type: 'ADD', item: product })}> 加入购物车 </button> ); }// Navbar.jsx import { useCart } from './CartContext'; function Navbar() { const { state } = useCart(); const count = state.items.reduce((sum, i) => sum + i.qty, 0); return <div>购物车({count})</div>; }到这里功能就通了。但有个性能问题埋在下面。
关键坑:Context 一变,全树重渲染
上面的写法有个隐患:value={{ state, dispatch }}每次CartProvider渲染都会创建一个新对象,而且 state 一变,所有调用useCart的组件都会重渲染——哪怕某个组件只用了dispatch、根本不关心 state 变化。
购物车小还好,状态一大、消费组件一多,就会出现「改一个字段全页面重渲」的卡顿。
解法:把 state 和 dispatch 拆成两个 Context。dispatch 是稳定不变的,只用 dispatch 的组件就不会因 state 变化而重渲:
const CartStateContext = createContext(null); const CartDispatchContext = createContext(null); export function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, initialState); return ( <CartStateContext.Provider value={state}> <CartDispatchContext.Provider value={dispatch}> {children} </CartDispatchContext.Provider> </CartStateContext.Provider> ); } export function useCartState() { const ctx = useContext(CartStateContext); if (ctx === null) throw new Error('useCartState 必须在 CartProvider 内'); return ctx; } export function useCartDispatch() { const ctx = useContext(CartDispatchContext); if (!ctx) throw new Error('useCartDispatch 必须在 CartProvider 内'); return ctx; }现在ProductCard只useCartDispatch(),state 怎么变它都不重渲;Navbar用useCartState(),只在 state 变时更新。dispatch的引用被 React 保证在整个生命周期里稳定,所以拆开后天然优化。
React 官方文档也推荐这种「State + Dispatch 双 Context」的写法,是社区里 Context+useReducer 方案的标配。
什么时候该上 Redux / Zustand
Context + useReducer 不是万能的,以下情况考虑更专业的库:
- 状态更新非常频繁(如实时协作、动画),即使拆了 Context 也扛不住,需要更细粒度的订阅(Zustand、Jotai 的 selector 机制)。
- 需要中间件生态:redux-persist 持久化、devtools 时间旅行调试、复杂异步流(redux-saga)。
- 状态逻辑跨很多模块,需要更强的组织约束。
反过来,中小型应用、状态更新不密集,Context + useReducer 足够了,还省一个依赖。别一上来就 Redux over-engineering。
小结
useReducer管「怎么变」(逻辑收拢进 reducer 纯函数),Context管「给谁用」(消除 prop drilling),两者组合是 React 内置的轻量全局状态方案。- 把
useContext封装成自定义 Hook(useCart),用起来更顺,还能校验 Provider 是否遗漏。 - 最大的坑是重渲染:
value每次新建对象 + state 变化触发全体消费者重渲。解法是拆成 State 和 Dispatch 两个 Context,只用 dispatch 的组件就不受 state 变化影响。 - 按规模选型:更新不密集的中小应用用 Context+useReducer;高频更新或要中间件生态再上 Zustand/Redux。
一句话记忆:Context 负责「送达」,useReducer 负责「变更」;性能卡了就把 state 和 dispatch 拆两个 Context——这是内置方案的天花板,够用就别上库。