陌上人如玉
公子世无双

3.6 全局状态选型

本文于 2026-10-13 08:07 更新,部分内容具有时效性,如有失效,请留言

3.6 全局状态选型

一句话:先分清「客户端状态」与「服务端状态」——前者用 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]);
}

九、面试延伸

  1. 「React 项目的状态管理该怎么做?」
    第一步是分类:① 本地状态(输入值、展开态)用 useState;② 低频跨层共享(主题/用户/权限)用 Context;③ 高频或复杂的全局客户端状态(购物车、编辑器、多步表单)用 Zustand / Redux;④ 服务端数据用 TanStack Query / SWR;⑤ 可分享/可刷新保持的状态(页码、筛选)放 URL;⑥ 表单用 react-hook-form。关键认知是「不是所有共享数据都该进全局 store」,尤其是服务端数据。

  2. 「Zustand 和 Redux 怎么选?」
    按「规模与需求」选:① Zustand——上手极快、无 Provider、选择器订阅、可在 React 之外读写、体积 1KB,中小项目的默认选择;② Redux Toolkit——适合大型项目、多团队协作、需要强规范与 DevTools、复杂中间件/异步编排的场景,代价是更多的概念与样板。经验法则是「没有明确理由就别上 Redux」,但如果是多人长期维护的大型应用,Redux 的约束与工具链确实有价值。

  3. 「为什么服务端数据不该放 Redux?」
    因为服务端数据和客户端状态的生命周期与需求完全不同:① 需要缓存(同一数据多个组件要用);② 需要失效策略(什么时候算过期、什么时候重新请求);③ 需要去重(多个组件同时请求同一接口);④ 需要重试与错误处理;⑤ 需要分页/无限滚动的缓存管理。用 Redux 自己实现这些会写大量样板代码且容易出错,而 TanStack Query / SWR 专门解决这些问题(staleTime、invalidateQueries、自动重试、乐观更新)。这也是近年「Redux 用得少了」的主要原因。

  4. 「Context 能替代状态管理库吗?」
    在小范围内可以(Context + useReducer 就是一个小型 store),但它有三个硬伤:① 没有选择器订阅——value 一变所有消费者都重渲染;② 不能在 React 之外读写(事件回调、工具函数里拿不到);③ 缺少中间件、DevTools、时间旅行等能力。所以:低频共享值用 Context(更好),高频或需要细粒度订阅时用状态库。

  5. 「Zustand 的选择器有什么坑?」
    最大的坑是「选择器返回新对象」:useStore((s) => ({ a: s.a, b: s.b })) 每次调用都返回新对象 → Object.is 比较失败 → 每次都重渲染。解法:① 分开订阅(useStore(s => s.a) + useStore(s => s.b));② 用 useShallow(浅比较)包装选择器;③ 只选择原始值或稳定引用。这一点与 Context 的 useMemo(value) 问题同源,都是「引用稳定」的问题。

  6. 「Jotai 和 Zustand 有什么区别?」
    心智模型不同:① Zustand 是「自下而上」的单 store 模型——一个 store 包含状态与操作,组件用选择器订阅其中的部分(类似「可选的 Context + 选择器」);② Jotai 是「原子化」模型——状态由一堆独立的小 atom 组成,派生关系自动追踪,组件只订阅自己用到的 atom(细粒度到「值」)。选择上:状态之间派生关系多、想要细粒度 → Jotai;想要一个集中的 store、团队熟悉 reducer 风格 → Zustand。

  7. 「状态应该怎么划分?」
    三个原则:① 按领域划分——userStore / cartStore / uiStore,每个负责一类业务数据,避免一个巨型 store;② 按生命周期划分——「登录后一直存在」的(用户信息)与「页面级」的(筛选条件)分开(页面级的更适合放 URL 或组件内);③ 按更新频率划分——高频的(拖拽/进度)与低频的(主题/配置)分开,避免高频更新影响低频订阅者。本质目标是把「耦合」控制在合理范围内。

  8. 「怎么避免全局状态滥用?」
    三条纪律:① 「就近原则」——能用组件内部状态解决的就不要提升;② 「能算出来的不要存」——派生值放渲染或选择器里算,不要存进 store;③ 「明确的准入标准」——只有「跨多个不相关组件、需要长期存活、需要被非 React 代码读写」的数据才进全局 store。此外可以定期 review store 里未被任何组件使用的字段(它们是「死状态」,只会增加认知负担)。

一句话速记

先分类再选型:本地状态 useState、低频跨层 Context、高频全局 Zustand(中小首选)/ Redux(大型规范)/ Jotai(细粒度派生)、服务端数据 TanStack Query、可分享状态放 URL、表单用 react-hook-form;最大误区是「什么都塞 Redux」(尤其服务端数据);Zustand 的坑是「选择器返回新对象」(用 useShallow 或分开订阅);Context 的硬伤是「没有选择器订阅 + 不能在 React 之外读写」;纪律是「就近原则 + 能算的不存 + 明确准入标准」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.6 全局状态选型

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫