知

模块二 · Hooks 与数据

第 10 / 18 节20 分钟阅读型

状态管理:useReducer、Context 与外部 store 的分工

状态管理不是"选一个库",而是把状态放到正确的层级。这一节讲清局部状态、Context 与 Zustand/Redux Toolkit 的边界,以及选择器如何避免全量重渲染。

读完这一节,你能做到

  1. 按"生命周期归属"给状态分层,而不是默认塞进全局 store。
  2. 用 useReducer 管理多字段联动的复杂状态。
  3. 用 Context 传依赖,而不是传频繁变化的数据。
  4. 在 Zustand 与 Redux Toolkit 之间做出有理由的选择。

一、先分层,再选库

层级例子工具
组件内输入框内容、展开状态useState
父级提升兄弟组件共享的选中项状态提升 + props
复合状态多字段联动、状态机式流转useReducer
跨层级只读主题、i18n、当前用户Context
跨页面共享购物车、草稿、偏好Zustand / Redux Toolkit
可分享/可回退筛选条件、分页、TabURL search params
服务端数据列表、详情请求缓存库

判断标准只有一句:谁拥有这个状态的生命周期。弹窗开关属于页面,放页面;登录态属于应用,放全局;筛选条件属于 URL,放 query。

一个常见反模式:项目一开始就建一个大 store,所有页面数据都往里塞,最后 store 变成"全局变量池",任何改动都可能影响任何页面。

二、useState 还是 useReducer

场景选择原因
一个独立值useState简单直接
多个字段总是一起变useReducer一次 dispatch 保证原子性
下一个状态依赖上一个useReducer逻辑集中在 reducer,易测
需要撤销/重做useReducer状态历史好维护
表单几十个字段表单库或 useReducer手写 useState 会爆炸
type State = {
  status: "idle" | "loading" | "success" | "error";
  data: Order[];
  error?: string;
};
type Action =
  | { type: "load" }
  | { type: "success"; data: Order[] }
  | { type: "error"; message: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "load":
      return { ...state, status: "loading", error: undefined };
    case "success":
      return { status: "success", data: action.data };
    case "error":
      return { ...state, status: "error", error: action.message };
  }
}

const [state, dispatch] = useReducer(reducer, { status: "idle", data: [] });

三个收益:

  1. 状态互斥:status 一个字段表达四态,不会出现"loading 为真又有 error"的非法组合。
  2. 可测试:reducer 是纯函数,直接传 state 与 action 断言输出,不需要渲染组件。
  3. 可读:业务动作被命名成 success/error,而不是散落在各个 setXxx 里。

代价是样板代码多。只有一个布尔值的时候不要用 reducer。

三、Context:传依赖,不传高频数据

const ThemeCtx = createContext<"light" | "dark">("light");

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  return (
    <ThemeCtx.Provider value={theme}>
      <Toolbar
        onToggle={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
      />
    </ThemeCtx.Provider>
  );
}

Context 的三个硬约束:

  1. value 变化会让所有消费者重渲染,与"用到哪部分"无关。所以不要把"频繁变化的数据 + 稳定方法"放同一个 Context。
  2. value 每次新建对象就等于每次都变,必须 useMemo。
  3. Context 不是状态管理库,它只解决"跨层级传递",不提供选择器、中间件、时间旅行。

正确的拆分方式:

<AuthCtx.Provider value={user}>
  {" "}
  {/* 低频变化 */}
  <ThemeCtx.Provider value={theme}>
    {" "}
    {/* 可能高频 */}
    <ActionsCtx.Provider value={actions}>{children}</ActionsCtx.Provider>
  </ThemeCtx.Provider>
</AuthCtx.Provider>

方法类数据放独立 Context,因为它们的引用稳定,不会引发额外渲染。

四、Zustand:最小可用的外部 store

import { create } from "zustand";

type CartState = {
  items: { id: string; price: number; qty: number }[];
  add: (item: { id: string; price: number }) => void;
  remove: (id: string) => void;
};

export const useCart = create<CartState>((set) => ({
  items: [],
  add: (item) =>
    set((s) => {
      const found = s.items.find((i) => i.id === item.id);
      return found
        ? {
            items: s.items.map((i) =>
              i.id === item.id ? { ...i, qty: i.qty + 1 } : i,
            ),
          }
        : { items: [...s.items, { ...item, qty: 1 }] };
    }),
  remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
}));

组件里用选择器订阅最小切片:

const count = useCart((s) => s.items.length);
const total = useCart((s) =>
  s.items.reduce((sum, i) => sum + i.price * i.qty, 0),
);

要点:

  • 选择器返回对象时必须用 useShallow,否则每次都是新引用,等于没优化。
  • action 定义在 store 里,组件只调用,不直接 set。
  • 需要持久化时用 persist 中间件,只持久化必要字段,不要整棵 store 落盘。

五、Redux Toolkit:什么时候值得

维度ZustandRedux Toolkit
学习成本低中
样板代码很少中等(slice)
DevTools支持最完善
中间件生态少丰富
团队约束弱强(action 流转清晰)
适合中小项目、快速迭代大团队、状态复杂、需要严格追踪

Redux Toolkit 的核心变化是"不再手写 action type 与 switch":

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] as Item[] },
  reducers: {
    add(state, action: PayloadAction<Item>) {
      state.items.push(action.payload); // Immer 允许"直接改"
    },
  },
});

createSlice 内部用 Immer,所以可以写"可变"代码,但本质仍是返回新状态。要注意:Immer 只对 draft 生效,别把 draft 存到外部。

六、选型决策表

状态是否只在组件内用?
├─ 是 → useState / useReducer
└─ 否 → 是否需要跨页面?
   ├─ 否 → 提升到共同父级,或 Context
   └─ 是 → 是否是服务端数据?
      ├─ 是 → 请求缓存库(TanStack Query)
      └─ 否 → 是否需要严格追踪与中间件?
         ├─ 是 → Redux Toolkit
         └─ 否 → Zustand

Jotai/Recoil 这类原子化方案适合"大量独立小状态、按需订阅"的场景,但团队不熟时收益不明显,不建议作为默认选择。

七、常见坑

  1. 所有状态塞进一个全局 store,store 变成变量池。
  2. Context 的 value 每次新建对象,所有消费者重渲染。
  3. 用 Context 存高频变化的数据(如鼠标位置、输入内容)。
  4. Zustand 选择器返回新对象却不用 useShallow,订阅形同虚设。
  5. 把服务端数据抄进 store,制造两份真相。
  6. reducer 里写副作用(发请求、改 localStorage),失去可测试性。
  7. 持久化整棵 store,版本升级后旧数据结构把应用打挂。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 把一个用五个 useState 的表单改成 useReducer,要求状态互斥且可单测。
  2. 用 Context 提供主题与"切换方法",拆成两个 Provider,用 Profiler 验证切换主题时方法消费者是否重渲染。
  3. 用 Zustand 实现购物车,组件通过选择器只订阅数量与总价。
  4. 给购物车加 persist,只持久化 items,并处理版本迁移。

② 让 AI 写同一题

把第 3 题(Zustand 购物车)交给 AI,补一句 组件只能订阅数量与总价,不能订阅整个 store。

③ 对比要盯的点

  • 选择器返回的是 s => ({ count: …, total: … }) 这种新对象吗?每次比较都是新引用,会反复重渲染,得配 useShallow。
  • persist 它配了 version 与 migrate 吗?改结构时没迁移会读到脏数据。
  • 它把表单的临时输入也塞进 store 了吗?那类状态不该进全局。

④ 一句话取舍

“选择器返回新对象”这个坑,为什么在 AI 版本里出现得特别频繁?

九、自测

  1. 状态分层的判断标准是什么?
  2. 什么情况下该用 useReducer?
  3. Context 为什么不适合放高频变化的数据?
  4. Zustand 选择器返回对象时要注意什么?
  5. Redux Toolkit 相比手写 Redux 省掉了什么?

参考答案要点:生命周期归属;多字段联动、依赖上一状态、需要历史;value 变化会让所有消费者重渲染;用 useShallow 稳定引用;action type 与 switch 样板。

附:store 拆分的三个信号

全局 store 越写越大,通常是拆分时机被错过了。出现下面任意一个信号,就该拆:

信号具体表现拆法
一个页面只用其中一部分引入 store 却只读一个字段按业务域拆成多个 store
改 A 导致 B 重渲染无关组件跟着刷新拆 store + 选择器订阅
action 名称开始带前缀orderAdd、orderRemove说明已经是两个领域

拆分的粒度按业务域,不按页面。用户、订单、购物车、偏好设置各一个 store;页面级状态(筛选、弹窗)留在组件或 URL 里。

跨 store 调用要克制。A store 直接读 B 的 state,会让依赖变成网状,调试时很难说清一次更新影响了谁。需要组合时,在组件层或一个专门的 action 里编排:

// 不推荐:store 之间直接 import
const useOrder = create((set) => ({
  add: (item) => {
    useCart.getState().add(item); // 隐式耦合
  },
}));

// 推荐:在调用方编排
function useAddToCart() {
  const addOrder = useOrder((s) => s.add);
  const addCart = useCart((s) => s.add);
  return (item) => {
    addOrder(item);
    addCart(item);
  };
}

最后一个判断标准:如果一个 store 被三个以上不相关的页面使用,它可能是“应用级状态”;如果只被一个页面用,它就不该在全局。按这个标准清理一遍,通常能删掉一半的 store 代码。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解