2917 字
约 9 分钟
17
S14-X17 · 状态管理的边界:组件状态、全局状态与服务端状态

状态管理的多数麻烦不是"库选错了",而是"这段状态本不该放在这里"。把服务端数据塞进全局 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. 自测

  1. 一段筛选条件既要在多个组件用、又要能分享链接,应该放哪?
  2. 为什么"自己用 useEffect 拉接口 + useState 存"会同时产生重复请求和不更新两个问题?
  3. Context 里放一个每秒变化的值,会影响哪些组件?
  4. staleTime 与 gcTime 分别决定什么?
  5. 原地修改数组为什么不触发重渲染?
  6. 派生状态重复存储后,最先出现的症状是什么?
S14-X17 · 状态管理的边界:组件状态、全局状态与服务端状态
https://www.clxhxhhr.top/posts/5916/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。