
一句话:先分清「客户端状态」与「服务端状态」——前者用
useState/ Context / Zustand / Redux,后者用 TanStack Query / SWR;不是所有共享数据都该进全局 store。
一、先给状态分类(最重要的一步)
| 类型 | 例子 | 该用什么 |
|---|---|---|
| 本地状态 | 输入框的值、hover、展开收起、动画进度 | useState / useReducer |
| 跨组件共享(低频) | 主题、语言、当前用户、权限 | Context |
| 跨组件共享(高频/复杂) | 购物车、编辑器状态、多步表单、全局筛选 | Zustand / Redux / Jotai |
| 服务端状态 | 列表数据、详情数据、分页结果 | TanStack Query / SWR |
| URL 状态 | 页码、筛选条件、Tab、搜索词 | useSearchParams(可分享、可刷新) |
| 表单状态 | 字段值、校验错误、touched | react-hook-form 或组件内 useState |
| 持久化状态 | 登录态、草稿、用户偏好 | localStorage + store,或 HttpOnly Cookie |
⚠️ 最常见的架构错误:
把所有东西都塞进 Redux(包括服务端数据、表单状态、UI 状态)
→ 大量样板代码、手动管理 loading/error/缓存、性能问题
正确做法是「按类型分流」:
服务端数据 → TanStack Query(自动缓存/失效/重试)
表单 → react-hook-form
URL → useSearchParams
真正的全局客户端状态 → Zustand/Redux
二、五种方案对比
| 维度 | Context | Redux Toolkit | Zustand | Jotai | MobX |
|---|---|---|---|---|---|
| 心智模型 | 依赖注入 | 单向数据流 + 纯函数 | 极简 store + 选择器 | 原子(atom) | 可观察对象 + 自动追踪 |
| 学习成本 | 低 | 中高 | 低 | 低中 | 中 |
| 样板代码 | 无 | 中(RTK 已大幅减少) | 极少 | 少 | 少 |
| 选择器订阅 | ❌ | ✅ | ✅ | ✅(原子级) | ✅ |
| 是否需 Provider | ❌ | ✅ | ❌ | ✅(可选) | ✅ |
| DevTools | ❌ | ✅ 最强 | ✅ | ✅ | ✅ |
| 中间件生态 | ❌ | ✅ 最丰富 | ✅(简单) | 中 | 中 |
| 异步处理 | 手动 | createAsyncThunk / RTK Query |
手动(简单) | 手动 | 手动 |
| 适合 | 低频共享值 | 大型/多团队/需规范 | 中小项目(推荐) | 细粒度、派生多的状态 | 复杂对象模型 |
三、Zustand(中小项目的默认选择)
import { create } from 'zustand';
// ① 定义 store(不需要 Provider!)
interface CartState {
items: CartItem[];
coupon: string | null;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
applyCoupon: (code: string) => void;
clear: () => void;
}
const useCartStore = create<CartState>((set, get) => ({
items: [],
coupon: null,
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
applyCoupon: (code) => set({ coupon: code }),
clear: () => set({ items: [], coupon: null }),
}));
// ② 组件里用「选择器」订阅(只有被选中的部分变化才重渲染)
function CartCount() {
const count = useCartStore((s) => s.items.length); // ⭐ 只订阅长度
console.log('CartCount 渲染');
return <span>{count}</span>;
}
function CartList() {
const items = useCartStore((s) => s.items);
const removeItem = useCartStore((s) => s.removeItem); // 函数引用稳定
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.name}<button onClick={() => removeItem(it.id)}>×</button></li>
))}
</ul>
);
}
// ⭐ 需要多个值时用 useShallow(避免每次都返回新对象导致重渲染)
import { useShallow } from 'zustand/react/shallow';
function CartSummary() {
const { items, coupon } = useCartStore(
useShallow((s) => ({ items: s.items, coupon: s.coupon }))
);
return <div>{items.length} 件 / {coupon ?? '无优惠'}</div>;
}
// ③ 在组件外读写(这是 Zustand 的一大优势)
useCartStore.getState().addItem(item);
useCartStore.setState({ coupon: 'SAVE10' });
useCartStore.subscribe((state) => console.log(state));
// ④ 持久化(官方中间件)
import { persist } from 'zustand/middleware';
const useCartStore2 = create<CartState>()(
persist(
(set) => ({ /* ... */ }),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }), // ⭐ 只持久化必要字段
}
)
);
Zustand 的核心优势:
① 不需要 Provider(直接 import 使用)
② 选择器订阅(只订阅用到的部分,性能好)
③ 可以在 React 之外读写(事件回调、WebSocket、工具函数)
④ API 极小(一个 create 函数)
⑤ 支持中间件(persist / immer / devtools / 自定义)
⑥ 体积小(约 1KB)
四、Jotai(细粒度、派生多的场景)
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// ① 基础原子
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// ② 派生原子(自动追踪依赖,依赖变化才重算)
const doubleAtom = atom((get) => get(countAtom) * 2);
function DoubleDisplay() {
const double = useAtomValue(doubleAtom); // ⭐ 只在 count 变化时重渲染
return <span>{double}</span>;
}
// ③ 可写派生原子
const countPlusAtom = atom(
(get) => get(countAtom) + 1,
(get, set, amount: number) => set(countAtom, get(countAtom) + amount)
);
// ④ 异步原子(自动处理 loading/error)
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
return fetch(`/api/users/${id}`).then((r) => r.json());
});
function UserProfile() {
// ⭐ 配合 Suspense:加载中显示 fallback
const user = useAtomValue(userAtom);
return <div>{user.name}</div>;
}
// 使用
<Suspense fallback={<Skeleton />}>
<UserProfile />
</Suspense>;
Jotai 的特点:
① 「自下而上」——状态由一堆独立的 atom 组成,组件只订阅自己用的 atom
② 派生关系自动追踪(不需要手写依赖数组)
③ 天然「细粒度」(原子级订阅)
④ 适合「状态之间派生关系多」的场景(如表单 + 联动 + 校验)
与 Zustand 的对比:
· Zustand:一个 store + 选择器(自上而下)
· Jotai:多个 atom 组合(自下而上)
两者都能满足绝大多数需求,团队熟悉哪个就用哪个。
五、Redux Toolkit(大型项目 / 需要规范)
// store/todos-slice.ts
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
// ① 异步 thunk
export const fetchTodos = createAsyncThunk('todos/fetch', async (userId: string) => {
const res = await api.getTodos(userId);
return res.data as Todo[];
});
// ② slice(reducer + action 一起定义)
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] as Todo[], status: 'idle' as 'idle' | 'loading' | 'success' | 'error', error: null as string | null },
reducers: {
addTodo(state, action: PayloadAction<Todo>) {
state.items.push(action.payload); // ⭐ RTK 内置 Immer,可以直接改
},
removeTodo(state, action: PayloadAction<string>) {
state.items = state.items.filter((t) => t.id !== action.payload);
},
toggleTodo(state, action: PayloadAction<string>) {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.status = 'loading'; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'success';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'error';
state.error = action.error.message ?? 'unknown';
});
},
});
export const { addTodo, removeTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
// 组件里使用
import { useAppDispatch, useAppSelector } from '@/store/hooks';
function TodoList() {
const items = useAppSelector((s) => s.todos.items);
const status = useAppSelector((s) => s.todos.status); // ⭐ 选择器订阅
const dispatch = useAppDispatch();
useEffect(() => {
if (status === 'idle') dispatch(fetchTodos('me'));
}, [status, dispatch]);
return <ul>{items.map((t) => <li key={t.id}>{t.text}</li>)}</ul>;
}
六、选型决策树
这份数据是「来自服务端」的吗?
├─ 是 → TanStack Query / SWR(不要放 Redux)
└─ 否 → 它能放在 URL 里吗?
├─ 是(页码/筛选/Tab)→ useSearchParams
└─ 否 → 需要被多个「不相关」的组件共享吗?
├─ 否 → useState / useReducer(就近管理)
└─ 是 → 变化频繁 / 逻辑复杂吗?
├─ 否(低频:主题/用户/权限)→ Context
└─ 是 → 规模与团队?
├─ 中小项目 → Zustand(推荐)
├─ 派生关系多、要细粒度 → Jotai
├─ 大型/多团队/要规范与 DevTools → Redux Toolkit
└─ 复杂对象模型 + 自动追踪 → MobX
七、常见选型误区
① 「所有状态都进 Redux」
→ 服务端数据、表单、UI 状态各有更合适的工具
② 「Context 就是状态管理」
→ Context 只解决「跨层级读取」,没有选择器订阅,高频数据会全量重渲染
③ 「状态管理库越大越好」
→ Redux 的能力很多项目用不到;Zustand 的简洁性往往更实际
④ 「用了状态库就不需要 props 了」
→ 父子通信仍然优先用 props(更显式、更好测试)
⑤ 「数据放全局就能到处用,很方便」
→ 全局状态越多,耦合越重、越难追踪「谁改了它」
⑥ 「Redux 一定要写 action type 常量、reducer 手写不可变更新」
→ 那是旧 Redux;RTK 已经内置 Immer 与 createSlice
⑦ 「用 Zustand 就不用管性能」
→ 仍需注意选择器粒度(返回新对象会导致重渲染,用 useShallow)
⑧ 「多个 store 一定比一个大 store 好」
→ 按领域拆 store 是好的(cart / user / ui),但拆太碎也会增加复杂度
八、常见坑
// ① Zustand 里返回「新对象」导致每次都重渲染
const { a, b } = useStore((s) => ({ a: s.a, b: s.b })); // ❌ 新对象
// ✅ 用 useShallow 或分开订阅
const a = useStore((s) => s.a);
const b = useStore((s) => s.b);
// ② 把「服务端数据」放进 store
const useStore = create(() => ({ users: [] }));
useEffect(() => { api.getUsers().then((users) => useStore.setState({ users })); }, []);
// ⚠️ 你要自己实现缓存、失效、重试、去重…… → 用 TanStack Query
// ③ 在 Redux 里放「表单临时状态」
// → 每次输入都 dispatch,样板多且性能差 → 用 react-hook-form
// ④ 选择器里做「重计算」
const total = useStore((s) => s.items.reduce((sum, i) => sum + i.price, 0)); // ⚠️ 每次渲染都算
// ✅ 在 store 里用派生逻辑或用 useMemo
// ⑤ Redux 里忘记用 Immer 的注意事项(不能「既改又返回」)
reducers: {
bad(state) { state.items.push(x); return { ...state }; } // ❌ 混用
}
// ⑥ Zustand 的 set 里「直接改 state」
set((state) => { state.items.push(x); }); // ❌ 需要返回新对象(除非配 immer 中间件)
// ⑦ 在组件外「读 store 但不订阅」→ 数据变化时 UI 不更新
const items = useStore.getState().items; // ⚠️ 只读一次
// ⑧ 用 Context 传 store 实例,再在组件里直接读整个 store
// ⑨ 忘记「状态的清理」(如登出时要重置多个 store)
// ⑩ 多个 store 之间「互相依赖」导致初始化顺序问题
// ⑨ 登出时重置所有 store 的做法
function useLogout() {
const resetCart = useCartStore((s) => s.clear);
const resetUser = useUserStore((s) => s.clear);
return useCallback(() => {
resetCart();
resetUser();
queryClient.clear(); // TanStack Query 的缓存也清掉
navigate('/login');
}, [resetCart, resetUser, navigate]);
}
九、面试延伸
- 「React 项目的状态管理该怎么做?」
第一步是分类:① 本地状态(输入值、展开态)用useState;② 低频跨层共享(主题/用户/权限)用 Context;③ 高频或复杂的全局客户端状态(购物车、编辑器、多步表单)用 Zustand / Redux;④ 服务端数据用 TanStack Query / SWR;⑤ 可分享/可刷新保持的状态(页码、筛选)放 URL;⑥ 表单用 react-hook-form。关键认知是「不是所有共享数据都该进全局 store」,尤其是服务端数据。 -
「Zustand 和 Redux 怎么选?」
按「规模与需求」选:① Zustand——上手极快、无 Provider、选择器订阅、可在 React 之外读写、体积 1KB,中小项目的默认选择;② Redux Toolkit——适合大型项目、多团队协作、需要强规范与 DevTools、复杂中间件/异步编排的场景,代价是更多的概念与样板。经验法则是「没有明确理由就别上 Redux」,但如果是多人长期维护的大型应用,Redux 的约束与工具链确实有价值。 -
「为什么服务端数据不该放 Redux?」
因为服务端数据和客户端状态的生命周期与需求完全不同:① 需要缓存(同一数据多个组件要用);② 需要失效策略(什么时候算过期、什么时候重新请求);③ 需要去重(多个组件同时请求同一接口);④ 需要重试与错误处理;⑤ 需要分页/无限滚动的缓存管理。用 Redux 自己实现这些会写大量样板代码且容易出错,而 TanStack Query / SWR 专门解决这些问题(staleTime、invalidateQueries、自动重试、乐观更新)。这也是近年「Redux 用得少了」的主要原因。 -
「Context 能替代状态管理库吗?」
在小范围内可以(Context + useReducer就是一个小型 store),但它有三个硬伤:① 没有选择器订阅——value 一变所有消费者都重渲染;② 不能在 React 之外读写(事件回调、工具函数里拿不到);③ 缺少中间件、DevTools、时间旅行等能力。所以:低频共享值用 Context(更好),高频或需要细粒度订阅时用状态库。 -
「Zustand 的选择器有什么坑?」
最大的坑是「选择器返回新对象」:useStore((s) => ({ a: s.a, b: s.b }))每次调用都返回新对象 →Object.is比较失败 → 每次都重渲染。解法:① 分开订阅(useStore(s => s.a)+useStore(s => s.b));② 用useShallow(浅比较)包装选择器;③ 只选择原始值或稳定引用。这一点与 Context 的useMemo(value)问题同源,都是「引用稳定」的问题。 -
「Jotai 和 Zustand 有什么区别?」
心智模型不同:① Zustand 是「自下而上」的单 store 模型——一个 store 包含状态与操作,组件用选择器订阅其中的部分(类似「可选的 Context + 选择器」);② Jotai 是「原子化」模型——状态由一堆独立的小 atom 组成,派生关系自动追踪,组件只订阅自己用到的 atom(细粒度到「值」)。选择上:状态之间派生关系多、想要细粒度 → Jotai;想要一个集中的 store、团队熟悉 reducer 风格 → Zustand。 -
「状态应该怎么划分?」
三个原则:① 按领域划分——userStore/cartStore/uiStore,每个负责一类业务数据,避免一个巨型 store;② 按生命周期划分——「登录后一直存在」的(用户信息)与「页面级」的(筛选条件)分开(页面级的更适合放 URL 或组件内);③ 按更新频率划分——高频的(拖拽/进度)与低频的(主题/配置)分开,避免高频更新影响低频订阅者。本质目标是把「耦合」控制在合理范围内。 -
「怎么避免全局状态滥用?」
三条纪律:① 「就近原则」——能用组件内部状态解决的就不要提升;② 「能算出来的不要存」——派生值放渲染或选择器里算,不要存进 store;③ 「明确的准入标准」——只有「跨多个不相关组件、需要长期存活、需要被非 React 代码读写」的数据才进全局 store。此外可以定期 review store 里未被任何组件使用的字段(它们是「死状态」,只会增加认知负担)。
一句话速记
先分类再选型:本地状态
useState、低频跨层 Context、高频全局 Zustand(中小首选)/ Redux(大型规范)/ Jotai(细粒度派生)、服务端数据 TanStack Query、可分享状态放 URL、表单用 react-hook-form;最大误区是「什么都塞 Redux」(尤其服务端数据);Zustand 的坑是「选择器返回新对象」(用useShallow或分开订阅);Context 的硬伤是「没有选择器订阅 + 不能在 React 之外读写」;纪律是「就近原则 + 能算的不存 + 明确准入标准」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!