React 学习站
React›进阶›进阶

状态管理与数据获取:选型逻辑

进阶进阶

一句话定义

React 只管「UI 组件状态」;超出组件树的客户端状态交给状态库(Zustand/Redux/Jotai),服务端状态(请求/缓存/同步)交给数据层(TanStack Query/SWR)——选型的核心是把两类状态分开,而不是挑「最流行」。

为什么重要

「要不要 Redux」曾争论十年,答案早已收敛为「按状态类型分流」。错误选型的代价是全应用胶水代码;正确分流能让 80% 的「全局状态」消失。这一篇给出可复用的决策框架,而不是API 教程。

前置知识

  • kp-005/kp-016 本地状态两层。
  • kp-012 Context 的定位。
  • 数据请求的基础(fetch、竞态——kp-010)。

核心概念

  • 状态三分法:

1. 组件状态:开关、输入值——useState/useReducer 就够。 2. 客户端全局状态:主题、当前用户、购物车——Context 或状态库。 3. 服务端状态:来自 API 的数据——数据层(带缓存、失效、重试、竞态处理)。

  • 服务端状态的特殊性:它不是「你的」,是远端数据的本地快照——会过期、会多人并发修改。用 useState+useEffect 手写缓存(kp-010 的样子)会在多组件间裂开。
  • Zustand:单 store + selector 订阅,样板极少;「没有 Provider」是它的卖点。
  • Redux Toolkit:单一 store、可预测、DevTools 时间旅行、中间件生态;适合大团队强约束。
  • Jotai/Recoil 风格(原子化):状态拆成细粒度 atom,依赖图自动最小化重渲染。
  • TanStack Query:useQuery({ queryKey, queryFn }) 自动处理缓存/去重/后台刷新/竞态/分页。

原理 / 机制

TanStack Query 替代手写 effect 请求的对比:

jsx// 手写(kp-010 的完整负担):竞态守卫 + loading/error + 缓存全要自己来
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
  let ignore = false;
  setLoading(true);
  fetchTodo(id).then(d => { if (!ignore) setData(d); })
    .catch(e => { if (!ignore) setError(e); })
    .finally(() => { if (!ignore) setLoading(false); });
  return () => { ignore = true; };
}, [id]);
// 还缺:组件卸载再挂载重新请求、多个组件用同一 id 各发一次、后台失效……

// TanStack Query:一行解决上述全部
const { data, isPending, error } = useQuery({
  queryKey: ['todo', id],
  queryFn: () => fetchTodo(id),
  staleTime: 30_000,
});

// 变更(写操作)
const mutation = useMutation({ mutationFn: createTodo, onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['todos'] });
}});

Zustand 的最小全局状态:

jsximport { create } from 'zustand';

const useCart = create((set, get) => ({
  items: [],
  add: item => set(s => ({ items: [...s.items, item] })),
  count: () => get().items.length,
}));

// 组件里用 selector 订阅 → 只订阅需要的切片,重渲染最小化
const count = useCart(s => s.items.length);
const add = useCart(s => s.add);

选型决策树:

text这个状态是 API 数据的快照吗?
 ├─ 是 → TanStack Query / SWR(不要进 Redux!)
 └─ 否(纯客户端)
     ├─ 只在一个组件/子树用 → useState / useReducer(kp-005/016)
     ├─ 跨树共享但低频(主题/用户)→ Context + 自定义 Hook(kp-012)
     └─ 跨树共享且高频或字段多
         ├─ 想要最少样板 → Zustand
         ├─ 想要强约束 + DevTools + 团队规范 → Redux Toolkit
         └─ 想要细粒度原子依赖 → Jotai

直观类比

状态分型像仓库管理:自家货物(客户端状态)自己管货架(store);租用仓位的货物(服务端数据)每天可能被别人搬动——你需要的是「登记什么时间看过哪个批次」的台账(缓存键 + 失效时间),而不是把货物搬进自家仓库再当真。把 API 数据塞进 Redux,等于把租来的货当成自己的——标签(缓存)一生效就全员过期货。

实例 / 案例

一个中型电商的技术栈组合(常见落地形态):

text路由/SSR        → Next.js(kp-026)
表单           → react-hook-form(非受控为主,性能好)
服务端数据      → TanStack Query(或 RSC 场景直接服务端取数)
客户端全局状态  → Zustand(购物车、UI 偏好)
鉴权/主题      → Context(低频、读多写少)
调试           → Redux DevTools(若用 RTK) / React DevTools

这个组合里 Redux 的缺席不是教条——若团队 20 人、需要严格 action 流审计与中间件(埋点、undo),RTK 依然是正解。选型看团队与规模,不看热度。

常见误区

  • 服务端状态进 Redux:缓存、失效、竞态全部手工重造轮子;这是十年争论的最终结论。
  • 一切状态先问「用什么库」:先分型,多数状态根本不需要库。
  • Context 存 API 数据:kp-012 的反模式延伸——高频失效 + 全树订阅。
  • selector 忘记使用:useCart() 整店订阅 → store 一动全页重渲染(性能回退,kp-019)。
  • 数据层与状态层职责重叠:把 Query 缓存的数据再拷贝进 Zustand「方便用」——两份真相必然失步;要么 selector 直读 Query 缓存,要么只放派生的 UI 状态。

自测题

  1. 写出状态三分法及各自的标准工具。
  2. 为什么「API 数据」不该放进 Redux/Zustand?TanStack Query 的 queryKey 相当于什么?
  3. Zustand 的 selector 解决什么问题?不写 selector 的后果是什么(用 kp-019 语言描述)?
  4. 给一个「团队 5 人、SaaS 后台、表单密集」的项目写出你的选型及理由。
  5. 「把 Query 的 data 拷进 store」错在哪里?

与其他知识点的关系

  • 向前:是 kp-005/012/015/016 全部本地方案的上层抽象。
  • 向后:kp-022/023 中数据层的 Suspense 模式与并发协作;kp-024 的 RSC 直接在服务端取数,改变「客户端请求数据」的部分前提;kp-026 展示这套组合在 Next.js 里的最终形态;kp-028 审查清单含「状态分型」检查项。

延伸阅读

  • Managing State(官方):<https://react.dev/learn/managing-state>
  • TanStack Query 概览:<https://tanstack.com/query/latest/docs/framework/react/overview>
  • Redux Toolkit:<https://redux-toolkit.js.org/>
  • Zustand:<https://zustand.docs.pmnd.rs/>