模块二 · Hooks 与数据
状态管理:useReducer、Context 与外部 store 的分工
状态管理不是"选一个库",而是把状态放到正确的层级。这一节讲清局部状态、Context 与 Zustand/Redux Toolkit 的边界,以及选择器如何避免全量重渲染。
读完这一节,你能做到
- 按"生命周期归属"给状态分层,而不是默认塞进全局 store。
- 用
useReducer管理多字段联动的复杂状态。 - 用 Context 传依赖,而不是传频繁变化的数据。
- 在 Zustand 与 Redux Toolkit 之间做出有理由的选择。
一、先分层,再选库
| 层级 | 例子 | 工具 |
|---|---|---|
| 组件内 | 输入框内容、展开状态 | useState |
| 父级提升 | 兄弟组件共享的选中项 | 状态提升 + props |
| 复合状态 | 多字段联动、状态机式流转 | useReducer |
| 跨层级只读 | 主题、i18n、当前用户 | Context |
| 跨页面共享 | 购物车、草稿、偏好 | Zustand / Redux Toolkit |
| 可分享/可回退 | 筛选条件、分页、Tab | URL 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: [] });
三个收益:
- 状态互斥:
status一个字段表达四态,不会出现"loading 为真又有 error"的非法组合。 - 可测试:
reducer是纯函数,直接传state与action断言输出,不需要渲染组件。 - 可读:业务动作被命名成
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 的三个硬约束:
- value 变化会让所有消费者重渲染,与"用到哪部分"无关。所以不要把"频繁变化的数据 + 稳定方法"放同一个 Context。
- value 每次新建对象就等于每次都变,必须
useMemo。 - 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:什么时候值得
| 维度 | Zustand | Redux 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 这类原子化方案适合"大量独立小状态、按需订阅"的场景,但团队不熟时收益不明显,不建议作为默认选择。
七、常见坑
- 所有状态塞进一个全局 store,store 变成变量池。
- Context 的 value 每次新建对象,所有消费者重渲染。
- 用 Context 存高频变化的数据(如鼠标位置、输入内容)。
- Zustand 选择器返回新对象却不用
useShallow,订阅形同虚设。 - 把服务端数据抄进 store,制造两份真相。
- reducer 里写副作用(发请求、改 localStorage),失去可测试性。
- 持久化整棵 store,版本升级后旧数据结构把应用打挂。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 把一个用五个
useState的表单改成useReducer,要求状态互斥且可单测。 - 用 Context 提供主题与"切换方法",拆成两个 Provider,用 Profiler 验证切换主题时方法消费者是否重渲染。
- 用 Zustand 实现购物车,组件通过选择器只订阅数量与总价。
- 给购物车加
persist,只持久化items,并处理版本迁移。
② 让 AI 写同一题
把第 3 题(Zustand 购物车)交给 AI,补一句 组件只能订阅数量与总价,不能订阅整个 store。
③ 对比要盯的点
- 选择器返回的是
s => ({ count: …, total: … })这种新对象吗?每次比较都是新引用,会反复重渲染,得配useShallow。 persist它配了version与migrate吗?改结构时没迁移会读到脏数据。- 它把表单的临时输入也塞进 store 了吗?那类状态不该进全局。
④ 一句话取舍
“选择器返回新对象”这个坑,为什么在 AI 版本里出现得特别频繁?
九、自测
- 状态分层的判断标准是什么?
- 什么情况下该用
useReducer? - Context 为什么不适合放高频变化的数据?
- Zustand 选择器返回对象时要注意什么?
- 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 代码。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ Context 适合做状态管理吗?它有什么性能问题?
- ★★★★☆ Redux、Zustand、Jotai 应该怎么选?
- ★★★★☆ Context、Redux、Zustand 怎么选?
提示:读完可以顺手做几道对应题目,检验是否真的理解