React 基础体系 · 第 9/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Context 与 Reducer:跨层状态、更新边界和可测试设计
React 中的 Context 和 Reducer 解决的是两个不同层次的问题:
Context解决“数据如何跨越组件层级传递”;Reducer解决“状态如何根据事件发生可预测的转换”。
它们经常组合使用,但并不是一个不可分割的整体。Context 可以提供 useState 的状态,useReducer 也可以完全不依赖 Context。理解两者的边界,才能避免把所有状态都塞进一个“全局 Context”。
一、先建立状态模型:状态、动作与状态转换
Reducer 的核心可以形式化为一个纯函数:
其中:
- 是第 次更新前的状态;
- 是描述发生了什么的动作;
- 是 reducer;
- 是更新后的状态。
例如,一个购物车状态可以表示为:
type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
status: "idle" | "submitting" | "success" | "error";
error: string | null;
};
动作描述意图,而不是直接描述“如何修改对象”:
type CartAction =
| {
type: "itemAdded";
item: Omit<CartItem, "quantity">;
}
| {
type: "itemRemoved";
productId: string;
}
| {
type: "checkoutStarted";
}
| {
type: "checkoutSucceeded";
}
| {
type: "checkoutFailed";
message: string;
}
| {
type: "reset";
};
Reducer 的约束是:
- 相同的
state和action应得到相同结果; - 不修改传入的
state; - 不执行网络请求、定时器、随机数或其他副作用;
- 对未知动作显式失败,而不是静默返回错误状态。
const initialCartState: CartState = {
items: [],
status: "idle",
error: null,
};
function cartReducer(
state: CartState,
action: CartAction,
): CartState {
switch (action.type) {
case "itemAdded": {
const existing = state.items.find(
item => item.productId === action.item.productId,
);
if (existing) {
return {
...state,
items: state.items.map(item =>
item.productId === action.item.productId
? { ...item, quantity: item.quantity + 1 }
: item,
),
};
}
return {
...state,
items: [
...state.items,
{
...action.item,
quantity: 1,
},
],
};
}
case "itemRemoved":
return {
...state,
items: state.items.filter(
item => item.productId !== action.productId,
),
};
case "checkoutStarted":
return {
...state,
status: "submitting",
error: null,
};
case "checkoutSucceeded":
return {
...state,
items: [],
status: "success",
error: null,
};
case "checkoutFailed":
return {
...state,
status: "error",
error: action.message,
};
case "reset":
return initialCartState;
default: {
const unreachable: never = action;
return unreachable;
}
}
}
这里的 never 检查依赖 TypeScript 的可辨识联合类型。如果以后给 CartAction 增加了新动作,却没有在 switch 中处理,编译器会在 const unreachable: never = action 处提示错误。这是把“状态转换必须完整”转化为编译期约束。
不可变更新为何重要
下面的写法会直接修改旧状态:
state.items.push(newItem);
return state;
这破坏了两个条件:
- reducer 不再是无副作用的状态转换;
- 顶层
state引用没有改变,React 和依赖引用比较的代码可能无法识别更新。
正确写法会创建发生变化的数组和对象:
return {
...state,
items: [...state.items, newItem],
};
不可变更新并不要求递归复制整个状态树。只复制从修改位置到根节点的路径即可。没有变化的子对象可以继续复用引用,这既保持了语义正确,也为后续的引用比较优化提供了条件。
二、Context 解决什么问题:跨层读取,而不是自动管理状态
如果没有 Context,位于页面顶部的状态需要通过每一层组件的 props 传给深层组件,即使中间组件根本不使用这些数据:
App
└── Layout
└── Main
└── ProductPage
└── AddToCartButton
Context 允许组件树上方的 Provider 向下方的消费者提供一个值:
<CartContext value={value}>
<ProductPage />
</CartContext>
深层组件可以直接读取:
const { state, dispatch } = useCart();
因此,Context 的本质是一个组件树范围内的依赖注入机制。它没有规定状态如何变化,也不负责持久化、缓存、请求去重或跨标签页同步。
Provider 的作用域
Context 的值只对 Provider 的后代有效:
<CartContext value={outerValue}>
<PageA />
<CartContext value={innerValue}>
<PageB />
</CartContext>
</CartContext>
在这个例子中:
PageA读取outerValue;PageB读取距离它最近的innerValue。
如果组件树中没有匹配的 Provider,消费者会读取 createContext 时设置的默认值。默认值不是运行时自动创建的状态,也不会在组件外部发生更新。
三、Context 与 Reducer 的组合:Provider 作为更新边界
将两者组合时,通常由 Provider 负责三件事:
- 创建 reducer 状态;
- 把状态和
dispatch放入 Context; - 限制哪些组件能够读取或发送这些动作。
下面是一个可直接放入客户端 React 项目的 TypeScript 示例。
"use client";
import {
createContext,
useContext,
useMemo,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
status: "idle" | "submitting" | "success" | "error";
error: string | null;
};
type CartAction =
| {
type: "itemAdded";
item: Omit<CartItem, "quantity">;
}
| {
type: "itemRemoved";
productId: string;
}
| {
type: "checkoutStarted";
}
| {
type: "checkoutSucceeded";
}
| {
type: "checkoutFailed";
message: string;
}
| {
type: "reset";
};
const initialCartState: CartState = {
items: [],
status: "idle",
error: null,
};
function cartReducer(
state: CartState,
action: CartAction,
): CartState {
switch (action.type) {
case "itemAdded": {
const existing = state.items.find(
item => item.productId === action.item.productId,
);
if (existing) {
return {
...state,
items: state.items.map(item =>
item.productId === action.item.productId
? { ...item, quantity: item.quantity + 1 }
: item,
),
};
}
return {
...state,
items: [
...state.items,
{
...action.item,
quantity: 1,
},
],
};
}
case "itemRemoved":
return {
...state,
items: state.items.filter(
item => item.productId !== action.productId,
),
};
case "checkoutStarted":
return {
...state,
status: "submitting",
error: null,
};
case "checkoutSucceeded":
return {
...state,
items: [],
status: "success",
error: null,
};
case "checkoutFailed":
return {
...state,
status: "error",
error: action.message,
};
case "reset":
return initialCartState;
default: {
const unreachable: never = action;
return unreachable;
}
}
}
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext =
createContext<Dispatch<CartAction> | null>(null);
export function CartProvider({
children,
}: {
children: ReactNode;
}) {
const [state, dispatch] = useReducer(
cartReducer,
initialCartState,
);
return (
<CartStateContext value={state}>
<CartDispatchContext value={dispatch}>
{children}
</CartDispatchContext>
</CartStateContext>
);
}
export function useCartState(): CartState {
const value = useContext(CartStateContext);
if (value === null) {
throw new Error(
"useCartState must be used inside <CartProvider>",
);
}
return value;
}
export function useCartDispatch(): Dispatch<CartAction> {
const value = useContext(CartDispatchContext);
if (value === null) {
throw new Error(
"useCartDispatch must be used inside <CartProvider>",
);
}
return value;
}
React 19 支持把 Context 本身写成 Provider:
<CartStateContext value={state}>
{children}
</CartStateContext>
在较早的 React 版本中,应写成:
<CartStateContext.Provider value={state}>
{children}
</CartStateContext.Provider>
如果项目需要兼容 React 19 之前的版本,应使用 .Provider 形式。
为什么要拆分 State Context 和 Dispatch Context
useReducer 返回的 dispatch 在组件生命周期内具有稳定身份。只发送动作、不读取状态的组件没有必要因为状态变化而重新读取整个状态 Context。
拆分后:
function AddToCartButton({
product,
}: {
product: Omit<CartItem, "quantity">;
}) {
const dispatch = useCartDispatch();
return (
<button
onClick={() => dispatch({ type: "itemAdded", item: product })}
>
加入购物车
</button>
);
}
AddToCartButton 只订阅 dispatch Context,不订阅状态 Context。购物车数量变化时,它不会因为 CartStateContext 的值改变而重新渲染。
这个拆分不能阻止所有重新渲染。例如,父组件因自身状态变化而重新渲染时,普通子组件仍可能重新渲染;拆分 Context 只解决“状态 Context 更新导致的订阅者更新”。
四、更新边界:谁能更新、更新传播到哪里
整个数据流可以表示为:
flowchart TD
A[用户事件] --> B[dispatch action]
B --> C[Reducer: R(state, action)]
C --> D[生成新 state]
D --> E[Provider value 更新]
E --> F[状态 Context 消费者]
F --> G[重新渲染并读取新快照]
一次 dispatch 的过程不是“立刻修改某个全局对象”,而是:
- 事件处理函数创建一个 action;
- React 调度 reducer;
- reducer 根据当前状态和 action 计算新状态;
- Provider 提供新的 Context 值;
- 读取该 Context 的组件获得新的状态快照;
- React 决定哪些组件需要重新渲染和提交。
Context 更新不是属性选择器
如果 Context 的值是:
{
user,
theme,
notifications,
}
某个组件只读取:
const { theme } = useContext(AppContext);
当 user 改变时,该组件仍然是这个 Context 的消费者。React 内置 Context 没有根据解构字段自动订阅 theme 的选择器;Provider 的 value 发生变化时,消费者会被通知。
因此,下面这种写法不能保证按字段隔离更新:
const value = useMemo(
() => ({ user, theme, notifications }),
[user, theme, notifications],
);
<AppContext value={value}>{children}</AppContext>
useMemo 只会在依赖变化时复用对象引用。当 user 改变时,value 仍然必须改变,所有该 Context 的消费者仍可能更新。它不能把一个大 Context 变成选择器系统。
更可靠的边界是按变化关系拆分 Context:
<UserContext value={user}>
<ThemeContext value={theme}>
<NotificationContext value={notifications}>
{children}
</NotificationContext>
</ThemeContext>
</UserContext>
拆分标准不是“每个变量一个 Context”,而是:
- 哪些数据总是一起读取;
- 哪些数据更新频率明显不同;
- 哪些功能应该拥有独立 Provider 作用域;
- 哪些组件只需要发送命令而不需要读取状态。
五、状态快照、批处理与 Reducer 的关系
React 状态不是一个可以在事件函数中同步读取并观察变化的普通变量。一次渲染得到的是一个状态快照:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
在同一次事件处理函数中:
- 两次
setCount(count + 1)都基于当前渲染中的同一个count; console.log(count)仍然读取旧快照;- React 通常会批量处理这些更新,最终结果通常是
count + 1,而不是count + 2。
Reducer 也遵循同样的快照和批处理模型:
function handleClick() {
dispatch({ type: "itemAdded", item: product });
dispatch({ type: "itemAdded", item: product });
}
不能依赖第一次 dispatch 后,函数中的 state 变量立即变成新值。Reducer 会按 React 的更新机制处理动作,组件在后续渲染中读取新的状态。
如果多个更新必须基于前一个更新的结果,Reducer 的动作序列比手动读取旧状态更明确:
S0 = { quantity: 0 }
A1 = increment
S1 = reducer(S0, A1) = { quantity: 1 }
A2 = increment
S2 = reducer(S1, A2) = { quantity: 2 }
这描述的是 React 处理动作时的状态转换顺序,不表示 dispatch 调用会同步改变当前函数闭包中的 state 变量。
六、异步操作:副作用在 Provider 外部,结果通过动作返回
Reducer 不应该直接执行网络请求:
// 错误示例
function reducer(state: State, action: Action) {
fetch("/api/checkout"); // 副作用
return state;
}
网络请求具有失败、取消、竞态和重复提交等行为,应该放在事件处理函数、Effect 或专门的数据层中。请求的结果再转换成 reducer 能理解的动作。
function CheckoutButton() {
const state = useCartState();
const dispatch = useCartDispatch();
async function handleCheckout() {
if (state.items.length === 0) {
return;
}
dispatch({ type: "checkoutStarted" });
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ items: state.items }),
});
if (!response.ok) {
throw new Error(`Checkout failed: ${response.status}`);
}
dispatch({ type: "checkoutSucceeded" });
} catch (error) {
const message =
error instanceof Error
? error.message
: "未知错误";
dispatch({
type: "checkoutFailed",
message,
});
}
}
return (
<button
disabled={
state.status === "submitting" || state.items.length === 0
}
onClick={handleCheckout}
>
{state.status === "submitting" ? "提交中…" : "结算"}
</button>
);
}
这个流程至少显式表达了三个中间状态:
idle
└─ checkoutStarted ──> submitting
├─ 成功 ───────> success
└─ 失败 ───────> error
如果允许用户在上一次请求尚未结束时再次发起请求,还需要处理竞态。例如,旧请求晚于新请求返回,可能覆盖新状态。常见处理方式包括:
- 在状态中加入请求标识
requestId; - 使用
AbortController取消旧请求; - 将请求交给专门的服务端缓存或数据获取库;
- 在 reducer 中只接受当前请求对应的成功或失败动作。
Context + Reducer 本身不会自动提供请求缓存、去重、重试、失效和跨页面同步能力。因此,购物车的本地交互状态适合它,而服务端查询缓存通常应交给 React Router 数据能力、TanStack Query、SWR 或其他专门工具。
七、初始化与懒初始化
如果初始状态需要解析大型 JSON、读取本地持久化数据或执行较昂贵的纯计算,可以使用 useReducer 的第三个参数:
type Preferences = {
darkMode: boolean;
};
function initPreferences(raw: string | null): Preferences {
if (!raw) {
return { darkMode: false };
}
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
"darkMode" in parsed &&
typeof parsed.darkMode === "boolean"
) {
return { darkMode: parsed.darkMode };
}
} catch {
// 使用安全默认值
}
return { darkMode: false };
}
function PreferencesProvider({
children,
}: {
children: ReactNode;
}) {
const [state, dispatch] = useReducer(
preferencesReducer,
null,
initPreferences,
);
return (
<PreferencesContext value={{ state, dispatch }}>
{children}
</PreferencesContext>
);
}
第三个参数接收初始化函数,第二个参数是初始化函数的输入。初始化逻辑必须考虑:
- 服务端渲染时不存在
window和localStorage; - 持久化数据可能损坏;
- 服务端和客户端初始输出不一致可能导致 hydration 问题;
- React 开发模式下某些初始化和渲染路径可能被额外调用以发现不纯逻辑。
因此,初始化函数应保持纯粹,并对不可用环境提供默认值。浏览器 API 读取通常应放在客户端 Effect 中,再通过 action 更新状态,而不是在服务端渲染阶段直接访问。
八、客户端与服务端边界
Context 是 React 组件树中的运行时机制。使用 Context Provider、useReducer、useContext 的模块必须处在允许使用客户端 Hook 的客户端边界内。
以支持 React Server Components 的主流框架为例,通常采用以下结构:
// app/layout.tsx —— 服务端组件
import { CartProvider } from "./CartProvider";
export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<CartProvider>{children}</CartProvider>
</body>
</html>
);
}
// app/CartProvider.tsx —— 客户端组件
"use client";
export function CartProvider({
children,
}: {
children: React.ReactNode;
}) {
// useReducer、Context 和客户端事件处理
return children;
}
关键边界是:
- 服务端组件可以把客户端 Provider 放入组件树;
- 客户端 Provider 可以包裹其后代客户端组件;
- 服务端组件不能直接读取客户端 Context 中随浏览器交互变化的状态;
- Context 不会把客户端状态自动传回服务器;
- 服务器上的请求数据、身份验证和数据库状态仍应由服务端逻辑负责。
客户端状态如果需要服务端确认,应通过请求、Server Action 或框架提供的数据提交机制发送出去,然后根据服务端结果 dispatch 成功或失败动作。不能把 Context 当作服务器上的共享可变内存。
九、常见失败方式与诊断方法
1. 忘记 Provider
错误表现通常是自定义 Hook 抛出异常:
useCartState must be used inside <CartProvider>
这种显式失败优于:
const CartContext = createContext<CartState>({
items: [],
status: "idle",
error: null,
});
后者会让 Provider 缺失时看起来像“有一个空购物车”,真正的组件树错误被隐藏。
诊断时检查:
- 消费者是否位于 Provider 的后代;
- 是否导入了同一个 Context 实例;
- 是否存在多个打包副本导致 Context 身份不同;
- 是否在测试中忘记包装 Provider。
2. Provider value 每次都创建新对象
以下代码会在 Provider 自身每次渲染时创建新对象:
const value = {
state,
dispatch,
};
<CartContext value={value}>{children}</CartContext>
如果 Provider 因其他原因重新渲染,即使 state 没有变化,Context 的 value 引用也可能改变。对于状态 Context 和 dispatch Context 已经拆分的设计,不需要把二者重新合成一个对象;如果确实必须合并,可考虑:
const value = useMemo(
() => ({ state, dispatch }),
[state, dispatch],
);
由于 dispatch 通常稳定,状态变化时 value 仍会变化,这是正确的;useMemo 只减少无关的 Provider 渲染造成的新引用,不能消除状态更新带来的通知。
3. 直接修改 Context 中的对象
const state = useCartState();
state.items[0].quantity += 1;
这绕过了 reducer,可能造成:
- React 不知道发生了什么;
- 更新无法形成明确的动作记录;
- 测试无法独立复现;
- 组件读取到被悄悄修改的对象;
- 并发渲染下出现难以推理的结果。
所有状态变更都应通过受约束的 action 进入 reducer。
4. 把派生数据重复存入状态
商品总价可以从 items 计算:
function CartSummary() {
const { items } = useCartState();
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return <strong>总价:{total}</strong>;
}
如果同时保存 items 和 total,就引入了不变量:
每个修改 items 的动作都必须同步修改 total。遗漏一次就产生不一致。因此,除非计算确实昂贵或需要保存服务端确认值,否则优先保存最小状态,派生值在读取处计算。
十、可测试设计:先测转换,再测边界
Reducer 是纯函数,因此不需要渲染组件就能测试:
import { describe, expect, it } from "vitest";
describe("cartReducer", () => {
it("adds a new item with quantity 1", () => {
const state = cartReducer(initialCartState, {
type: "itemAdded",
item: {
productId: "p-1",
name: "Keyboard",
price: 199,
},
});
expect(state.items).toEqual([
{
productId: "p-1",
name: "Keyboard",
price: 199,
quantity: 1,
},
]);
expect(initialCartState.items).toEqual([]);
});
it("increments quantity for an existing item", () => {
const previous: CartState = {
...initialCartState,
items: [
{
productId: "p-1",
name: "Keyboard",
price: 199,
quantity: 1,
},
],
};
const next = cartReducer(previous, {
type: "itemAdded",
item: {
productId: "p-1",
name: "Keyboard",
price: 199,
},
});
expect(next.items[0].quantity).toBe(2);
expect(previous.items[0].quantity).toBe(1);
});
});
这些测试验证了三个事实:
- action 产生了预期的状态;
- 原状态没有被修改;
- 相同输入可以得到稳定结果。
然后再测试 Provider 和消费者之间的连接:
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { CartProvider } from "./CartProvider";
function TestConsumer() {
const state = useCartState();
const dispatch = useCartDispatch();
return (
<>
<output data-testid="count">
{state.items.reduce(
(sum, item) => sum + item.quantity,
0,
)}
</output>
<button
onClick={() =>
dispatch({
type: "itemAdded",
item: {
productId: "p-1",
name: "Keyboard",
price: 199,
},
})
}
>
add
</button>
</>
);
}
it("connects dispatch to the context state", async () => {
const user = userEvent.setup();
render(
<CartProvider>
<TestConsumer />
</CartProvider>,
);
expect(screen.getByTestId("count")).toHaveTextContent("0");
await user.click(screen.getByRole("button", { name: "add" }));
expect(screen.getByTestId("count")).toHaveTextContent("1");
});
组件测试关注的是 Provider、Hook 和消费者之间的连接;业务规则则应尽量由 reducer 单元测试覆盖。这样失败时可以区分:
- reducer 算错;
- action 类型或字段传错;
- Provider 没有包裹组件;
- 异步请求错误没有转换成失败 action;
- UI 没有正确渲染状态。
如果需要测试缺失 Provider 的错误,可以单独渲染 TestConsumer,并断言自定义 Hook 抛出预期异常。
十一、Context + Reducer 的适用边界
这种组合适合以下状态:
- 组件树内多个相邻功能需要共享;
- 状态转换有多个动作和明确业务规则;
- 希望把状态规则从视图中分离;
- 状态生命周期与某个页面、表单、编辑器或工作区绑定;
- 需要通过 Provider 控制作用域,便于测试和复用。
它不自动适合以下问题:
服务端缓存
用户列表、商品详情、消息查询等数据通常具有缓存、重新验证、请求去重和失效关系。将它们复制到 Context 中,往往需要自行实现大量缓存逻辑。
高频、细粒度更新
如果一个大状态树每秒频繁更新,而许多组件只读取其中一个小字段,单一 Context 会造成较宽的更新传播范围。可以拆分 Context,或选择带 selector、细粒度订阅机制的状态库。
跨页面或跨标签页持久化
Context 的作用域是当前 React 树。刷新页面、打开新标签页或关闭当前树后,Context 状态不会自动保留。持久化需要 URL、服务器、localStorage、IndexedDB 或其他外部存储,并且要处理同步和冲突。
全局事件总线
Reducer 的 action 流可以集中管理状态转换,但它不是任意模块都能写入的全局事件总线。跨越多个独立 React 根节点时,Context 也无法直接共享值,需要外部存储或共同的上层根节点。
十二、如何判断该用 Context、Reducer 还是其他方案
可以按问题的边界判断:
-
只有一个组件或很浅的层级使用状态
优先使用useState或局部useReducer。 -
同一棵组件树中存在 prop drilling,且状态归属于这棵树
使用 Context;状态简单时 Context +useState即可。 -
动作较多、状态转换有业务规则、需要独立测试
使用 Context + Reducer,并拆分状态与 dispatch Context。 -
服务端数据需要缓存、失效、重试或请求去重
使用专门的服务端状态工具,不要仅依赖 Context + Reducer。 -
多个不相关 React 根节点或非 React 模块需要共享状态
考虑外部 store;Context 只能覆盖 Provider 的后代。 -
状态更新非常频繁且消费者只关心小片段
先按更新关系拆分 Context;如果仍然不够,再选择支持细粒度订阅的状态方案。
最终的设计重点不是“Context 还是 Reducer”,而是分别回答两个问题:
- 状态应该对哪棵组件树可见;
- 哪些动作可以把状态从一个有效状态转换到另一个有效状态。
Context 划定可见范围,Reducer 划定更新规则。把这两个边界分开后,跨层状态不再等同于不可控的全局变量,状态转换也可以在脱离 UI 的情况下被推导、测试和验证。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 表单工程:受控组件、校验、异步提交、错误与性能
- 下一篇:React Router 完整指南:嵌套路由、Loader、Action、错误与权限
- 延伸:React State 与 Hooks:useState、useReducer、批处理和状态快照
- 延伸:React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论