React 基础体系 · 第 9/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Context 与 Reducer:跨层状态、更新边界和可测试设计

React 中的 ContextReducer 解决的是两个不同层次的问题:

  • Context 解决“数据如何跨越组件层级传递”;
  • Reducer 解决“状态如何根据事件发生可预测的转换”。

它们经常组合使用,但并不是一个不可分割的整体。Context 可以提供 useState 的状态,useReducer 也可以完全不依赖 Context。理解两者的边界,才能避免把所有状态都塞进一个“全局 Context”。


一、先建立状态模型:状态、动作与状态转换

Reducer 的核心可以形式化为一个纯函数:

Sn+1=R(Sn,An)S_{n+1} = R(S_n, A_n)

其中:

  • SnS_n 是第 nn 次更新前的状态;
  • AnA_n 是描述发生了什么的动作;
  • RR 是 reducer;
  • Sn+1S_{n+1} 是更新后的状态。

例如,一个购物车状态可以表示为:

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 的约束是:

  1. 相同的 stateaction 应得到相同结果;
  2. 不修改传入的 state
  3. 不执行网络请求、定时器、随机数或其他副作用;
  4. 对未知动作显式失败,而不是静默返回错误状态。
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 负责三件事:

  1. 创建 reducer 状态;
  2. 把状态和 dispatch 放入 Context;
  3. 限制哪些组件能够读取或发送这些动作。

下面是一个可直接放入客户端 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 的过程不是“立刻修改某个全局对象”,而是:

  1. 事件处理函数创建一个 action;
  2. React 调度 reducer;
  3. reducer 根据当前状态和 action 计算新状态;
  4. Provider 提供新的 Context 值;
  5. 读取该 Context 的组件获得新的状态快照;
  6. 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>
  );
}

第三个参数接收初始化函数,第二个参数是初始化函数的输入。初始化逻辑必须考虑:

  • 服务端渲染时不存在 windowlocalStorage
  • 持久化数据可能损坏;
  • 服务端和客户端初始输出不一致可能导致 hydration 问题;
  • React 开发模式下某些初始化和渲染路径可能被额外调用以发现不纯逻辑。

因此,初始化函数应保持纯粹,并对不可用环境提供默认值。浏览器 API 读取通常应放在客户端 Effect 中,再通过 action 更新状态,而不是在服务端渲染阶段直接访问。


八、客户端与服务端边界

Context 是 React 组件树中的运行时机制。使用 Context Provider、useReduceruseContext 的模块必须处在允许使用客户端 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 缺失时看起来像“有一个空购物车”,真正的组件树错误被隐藏。

诊断时检查:

  1. 消费者是否位于 Provider 的后代;
  2. 是否导入了同一个 Context 实例;
  3. 是否存在多个打包副本导致 Context 身份不同;
  4. 是否在测试中忘记包装 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>;
}

如果同时保存 itemstotal,就引入了不变量:

total=ipricei×quantityitotal = \sum_i price_i \times quantity_i

每个修改 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);
  });
});

这些测试验证了三个事实:

  1. action 产生了预期的状态;
  2. 原状态没有被修改;
  3. 相同输入可以得到稳定结果。

然后再测试 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 还是其他方案

可以按问题的边界判断:

  1. 只有一个组件或很浅的层级使用状态
    优先使用 useState 或局部 useReducer

  2. 同一棵组件树中存在 prop drilling,且状态归属于这棵树
    使用 Context;状态简单时 Context + useState 即可。

  3. 动作较多、状态转换有业务规则、需要独立测试
    使用 Context + Reducer,并拆分状态与 dispatch Context。

  4. 服务端数据需要缓存、失效、重试或请求去重
    使用专门的服务端状态工具,不要仅依赖 Context + Reducer。

  5. 多个不相关 React 根节点或非 React 模块需要共享状态
    考虑外部 store;Context 只能覆盖 Provider 的后代。

  6. 状态更新非常频繁且消费者只关心小片段
    先按更新关系拆分 Context;如果仍然不够,再选择支持细粒度订阅的状态方案。

最终的设计重点不是“Context 还是 Reducer”,而是分别回答两个问题:

  • 状态应该对哪棵组件树可见;
  • 哪些动作可以把状态从一个有效状态转换到另一个有效状态。

Context 划定可见范围,Reducer 划定更新规则。把这两个边界分开后,跨层状态不再等同于不可控的全局变量,状态转换也可以在脱离 UI 的情况下被推导、测试和验证。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。