状态管理的多数麻烦不是"库选错了",而是"这段状态本不该放在这里"。把服务端数据塞进全局 store、把高频变化的值放进 Context、把可分享的筛选条件藏在组件里,之后每加一个功能都要补一次同步逻辑。反过来,先判断状态属于哪一类,库的选择往往只剩一两个候选。
按分类 → 判断 → 边界 → 库定位 → 更新方式拆。
1. 四类状态,先分类再选库
| 类型 | 来源 | 生命周期 | 典型载体 |
|---|---|---|---|
| 局部 UI 状态 | 组件内部事件 | 组件挂载期间 | useState / useReducer |
| 跨组件共享状态 | 用户操作、多组件协作 | 会话期间,可能持久化 | Context / Zustand / Redux |
| 服务端数据状态 | 接口返回 | 由缓存策略决定 | React Query / SWR / RSC |
| URL 状态 | 地址栏 | 与历史记录一致 | useSearchParams / 路由参数 |
四类的差别在生命周期与真相来源,不在"要不要共享":服务端数据的真相在服务端,客户端只是快照;URL 的真相在地址栏。把这两类当本地状态管,就必然要自己写失效与同步。
2. 判断一段状态放哪里的四个问题
| 问题 | 指向 | 结论 |
|---|---|---|
| 谁会读它? | 只有一个组件 | 留在组件内 |
| 它活多久? | 随组件卸载即失效 | 组件内;跨路由仍要保留才上提 |
| 是否来自服务端? | 接口返回、需要失效 | 交给服务端缓存层,不手写 store |
| 是否需要可分享链接? | 筛选、分页、Tab | 放 URL |
只要有一个答案是"服务端"或"URL",就不该进全局 store;剩下的纯客户端共享状态,再按变更频率决定 Context 还是订阅式 store。
// 局部:只在这个组件里读写
const [open, setOpen] = useState(false);
// URL:筛选条件要能分享、能后退
const [params, setParams] = useSearchParams();
const page = Number(params.get('page') ?? 1);
// 服务端:交给缓存层,别复制进 store
const { data } = useQuery({ queryKey: ['orders', page], queryFn: fetchOrders });
3. 服务端状态与客户端状态混放会出什么事
自己实现一份"服务端数据缓存",短期能跑,长期一定缺三样东西:失效、去重、重试。
| 症状 | 机制 | 后果 |
|---|---|---|
| 数据不更新 | 自建缓存没有失效入口 | 后台改了,前台一直是旧值 |
| 同一接口重复请求 | 多个组件各自 useEffect 拉取 |
请求数翻倍,后端限流触发 |
| 刷新后状态丢失 | 只存在内存 store | 用户每次刷新都回到空态 |
| 竞态覆盖 | 后发的旧响应覆盖新响应 | 列表偶发显示错数据 |
| 分页/筛选不同步 | 参数存在 store、URL 没变 | 分享链接打开是默认视图 |
// 反例:把服务端数据当本地状态
const [orders, setOrders] = useState([]);
useEffect(() => { fetch('/api/orders').then(r => r.json()).then(setOrders); }, []);
// 每次挂载都请求、没有失效、没有去重、没有错误重试
// 正例:缓存层负责生命周期
const { data, isPending, refetch } = useQuery({
queryKey: ['orders', { page }],
queryFn: () => fetchOrders(page),
staleTime: 30_000, // 30 秒内视为新鲜,不重复请求
});
staleTime 决定"多久算新鲜",gcTime(旧版 cacheTime)决定"卸载后缓存保留多久";两个都设 0 等于每次挂载都重请求。
4. URL 作为状态:能分享的那部分
| 内容 | 放 URL | 理由 |
|---|---|---|
| 分页、排序、筛选 | 是 | 可分享、可后退、刷新保持 |
| 当前 Tab / 展开的面板 | 视情况 | 需要分享时才放 |
| 搜索关键词 | 是 | 与筛选同理 |
| 表单草稿、输入中的值 | 否 | 每次按键都写历史,后退键会被污染 |
| 临时弹窗、hover 态 | 否 | 属于纯 UI 瞬态 |
| 敏感 token | 否 | 会进历史、日志、Referer |
// 写入用 replace 还是 push,取决于是否要留历史
const next = new URLSearchParams(params);
next.set('page', String(page));
setParams(next, { replace: true }); // 翻页替换;切筛选用 push
代价是序列化:数字、布尔、数组都要约定编码,读取处要校验并给默认值——page=abc 会让 NaN 一路传到接口。
5. Context 的适用边界
| 场景 | 适合 Context | 说明 |
|---|---|---|
| 主题、语言、当前用户 | 是 | 低频变更,读多写少 |
| 依赖注入(API client、i18n) | 是 | 基本不变 |
| 高频输入(鼠标位置、滚动值) | 否 | 每次变更让所有消费者重渲染 |
| 大型列表的行选中态 | 否 | 用订阅式 store 或局部状态 |
Context 的机制是"值变了,所有消费者重渲染",且 React 无法按字段跳过。所以一个 Context 里塞 { user, theme, modalOpen },改弹窗也会让读 user 的组件重渲染。
// 拆成两个 Provider:状态与更新函数分离
const UserContext = createContext<User | null>(null);
const UserActionsContext = createContext<{ logout: () => void } | null>(null);
// 消费方只依赖 actions,就不会因为 user 对象换了引用而重渲染
function LogoutButton() {
const { logout } = useAuthActions(); // 只订阅 actions
return <button onClick={logout}>退出</button>;
}
要传高频值又不想整树重渲染,用 useSyncExternalStore 订阅外部 store(Zustand 内部即此机制),只更新订阅了该切片的组件。
6. 常见库的定位对照
| 库 | 模型 | 适合 | 不适合 |
|---|---|---|---|
| Redux / RTK | 单一 store + 可预测的状态机、action 日志 | 多人协作、需要时间旅行调试、复杂业务规则 | 小项目、纯表单页 |
| Zustand | 轻量 store + selector 订阅 | 中小型共享状态、跨路由的 UI 状态 | 需要严格 action 审计的团队 |
| React Query / SWR | 服务端缓存 + 失效策略 | 一切接口数据 | 纯本地 UI 状态 |
| Jotai / Recoil | 原子化,按 atom 订阅 | 细粒度、相互独立的状态片 | 状态间强耦合、需要集中审计 |
| Context + useReducer | 内置,无依赖 | 低频配置、单页面内共享 | 高频更新、跨页面共享 |
选型顺序建议是:先确认没有服务端数据混进来,再看变更频率与团队规模。React Query 和 Zustand 可以共存——前者管接口缓存,后者管纯客户端 UI 状态,两者职责不重叠。
7. 不可变性要求与它的性能含义
React 靠引用相等判断"要不要重渲染"。原地改对象(obj.x = 1)不会换引用,组件看不到变化;而每次更新都深拷贝整棵树,又会让没变的子节点全部重渲染。
| 写法 | 引用变化 | 结果 |
|---|---|---|
state.list.push(x) |
否 | 不触发渲染 |
{ ...state, list: [...state.list, x] } |
是(顶层与新数组) | 正确更新,未变分支保持引用 |
{ ...state, list: state.list.map(i => i.id === id ? {...i, done: true} : i) } |
局部 | 只有目标项换引用,配合 memo 可跳过其余项 |
structuredClone(state) |
全部 | 正确但所有子节点重渲染 |
// 一层展开够用,就别上深拷贝
const next = { ...state, filters: { ...state.filters, page: 2 } };
// 层级深时用 immer 的草稿写法,产出仍是不可变结构
import { produce } from 'immer';
const next2 = produce(state, (draft) => { draft.filters.page = 2; });
成本差异很直接:不可变更新的成本与"变更路径深度"成正比,深拷贝与"整棵树大小"成正比——扁平化状态的动机在成本,不在审美。
8. 派生状态不要重复存储
能从已有状态算出来的值,再存一份就多了一个必须同步的地方。
// 反例:total 与 items 双写,任何一处漏改就不一致
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
// 正例:算出来,或用 selector 缓存
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
// Zustand:selector 只在切片变化时通知
const count = useStore((s) => s.items.length);
useMemo 的意义是避免"每次渲染都重算",不是"缓存数据"。依赖数组写错时它比不写更糟——闭包捕获旧值,界面显示过期结果。计算本身很便宜时(数组长度、简单求和),直接算比记忆化更可预测。
9. 常见坑
| 坑 | 表现 | 根因 | 改法 |
|---|---|---|---|
| 所有状态都进全局 store | store 越写越大、组件订阅过多 | 把"共享"当成默认 | 按第 2 节四问逐个回退到局部 |
| 自己管服务端缓存 | 数据不更新、请求翻倍 | 缺失效与去重 | 换 React Query / SWR |
| Context 里放高频值 | 输入卡顿、整页重渲染 | Context 无法按字段跳过 | 拆 Context 或改订阅式 store |
| 状态嵌套过深 | 更新代码冗长、易漏层 | 结构按 UI 而非按数据建模 | 扁平化,用 id 关联 |
| 派生值双写 | 偶发数字对不上 | 两处更新不同步 | 用 selector 计算 |
10. 排错表
| 现象 | 优先核对 | 处理 |
|---|---|---|
| 改了 store 界面不动 | 是否原地修改、selector 是否返回新对象 | 改不可变更新;selector 返回稳定引用 |
| 输入框每敲一个字都卡 | Context 里是否有高频值 | 拆 Provider 或用 useSyncExternalStore |
| 接口请求数量异常 | 是否有多个 useEffect 各自拉取 |
收敛到缓存层,用同一 queryKey |
| 刷新后筛选条件丢失 | 状态在内存而非 URL | 迁到 searchParams |
11. 边界:什么时候不需要状态管理库
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 单页表单、无跨组件共享 | useState + 受控组件 |
引入 store 只增加间接层 |
| 数据只在服务端用 | RSC 直接取数 | 不进客户端状态,省一次水合 |
| 状态由 URL 决定 | 路由参数 + searchParams | 天然可分享可刷新 |
| 只有少量全局配置 | Context | 无需额外依赖 |
一句话收束:边界由"真相在哪里"决定,不由"组件在哪"决定。服务端数据归缓存层,可分享的归 URL,瞬态归组件,剩下的才归全局 store。
12. 自测
- 一段筛选条件既要在多个组件用、又要能分享链接,应该放哪?
- 为什么"自己用
useEffect拉接口 +useState存"会同时产生重复请求和不更新两个问题? - Context 里放一个每秒变化的值,会影响哪些组件?
staleTime与gcTime分别决定什么?- 原地修改数组为什么不触发重渲染?
- 派生状态重复存储后,最先出现的症状是什么?