状态管理选型与状态分类
状态管理选型的第一步不是选库,而是把状态分类。分错类,用什么库都会难受。
一、按来源把状态分五类
选错库的根因几乎都是没先分类。先问五个问题:
① 这份数据的真值在服务端吗? —— 是,就是服务端数据(用户资料、订单列表、商品价格)。它的特点是异步、会过期、需要缓存与重新验证。
② 它能不能从 URL 推导出来? —— 能,就是路由/URL 状态(搜索词、筛选条件、页码、当前 tab)。它的特点是应当可分享、可刷新保留、可前进后退。
③ 它是用户正在输入或操作的临时数据吗? —— 是,就是表单与交互态(输入框的值、正在拖拽的位置、草稿)。它的特点是高频变化、只属于当前这一个界面。
④ 它是全局生效的界面配置吗? —— 是,就是全局 UI 态(主题、语言、侧栏展开、权限开关)。它的特点是低频变化、读取点多。
⑤ 它需要被不相干的多个组件读写吗? —— 需要,就是共享领域态(购物车、编辑器选中集、多步流程的进度)。
// 反面例子:五类状态全塞进一个 store
const useStore = create((set) => ({
user: null, // ① 服务端数据
keyword: '', // ② 本该在 URL 里
draftTitle: '', // ③ 本该是组件本地 state
theme: 'light', // ④ 全局 UI 态(这个放这里还行)
cart: [], // ⑤ 共享领域态(这个也还行)
}))
五类的最佳归属完全不同。把它们塞进同一个 store,意味着你要自己实现缓存失效、URL 同步、表单校验——而这三件事各有专门的工具已经做好了。
二、Context 装一切
把服务端数据放进全局 store,是最贵的一个错误。
一旦这么做,下面五件事就全要自己写:缓存(要不要重新请求)、失效(什么时候算过期)、重试(失败了怎么办)、竞态(先发的慢请求覆盖后发的快请求)、分页(滚动加载的中间态)。而这些恰恰是请求库已经处理好的。
判定标准只有一条:这份数据的真值在服务端,就不该长期住在客户端 store 里。
// 改前:接口数据进 store,缓存与失效全靠手写
const { users, setUsers } = useStore()
useEffect(() => {
fetchUsers(page).then(setUsers)
}, [page])
// 改后:交给请求层,缓存/重试/竞态都内置
const { data: users } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
})
第二个常见错误是状态放得太高。一个只有某个输入框用到的值,被提到页面顶层,于是每次按键整棵子树重渲染。修法是就近放置:状态离使用它最近的那个组件越近越好,只有确实需要共享时才往上提。
第三个是用 useEffect 派生状态。能算出来的就不要存,const fullName = first + last 比 useState + useEffect 同步一遍简单得多,也少了一类「忘了同步」的 bug。
三、四个库各自的甜区
按「它主要解决哪一类状态」来定位,而不是比功能多少:
| 方案 | 主要位置 | 适合 | 代价 |
|---|---|---|---|
| 请求库(TanStack Query 一类) | ① 服务端数据 | 缓存、重新验证、乐观更新、分页 | 只管服务端数据,不能当通用 store |
| Zustand | ④⑤ 共享与全局态 | 轻量、无需 Provider、选择器订阅 | 需要自己约定组织方式 |
| Jotai | ④⑤ 原子化状态 | 细粒度订阅、依赖派生自然 | 原子拆分需要设计 |
| Redux Toolkit | ⑤ 复杂共享态 | 可预测、可追溯、中间件生态 | 样板代码多,小项目是净负担 |
| MobX | ⑤ 领域模型 | 响应式、面向对象模型友好 | 可变语义与 React 的不可变假设有摩擦 |
一句话:服务端数据归请求库,客户端共享态归 Zustand/Jotai,复杂可追溯场景才上 Redux。
四、Context 该装什么
Context 的问题不是「能不能用」,而是变化频率。
低频、小范围、读取点多的配置型数据(主题、语言、权限、Feature Flag)用它非常合适——不需要引入任何依赖,语义也清晰。
高频变化的值放进 Context 则是灾难:Provider 的 value 一变,所有消费者全部重渲染,而且你没法只订阅其中一部分。
// 错:搜索词放进 Context,每次按键整棵树重渲染
<AppContext.Provider value={{ keyword, setKeyword, theme, user }}>
// 改进一:按领域拆分 Provider,变化频率相近的放一起
<ThemeContext.Provider value={theme}>
<FilterContext.Provider value={{ keyword, setKeyword }}>
// 改进二:高频值干脆不要全局化,就近放在用到的组件里
function SearchBar() {
const [keyword, setKeyword] = useState('')
// ...
}
另一个常被忽略的点:即使拆了 Provider,只要 value 是个新对象(value={{ a, b }}),每次渲染都是新引用。要么用 useMemo 包住,要么拆成多个简单值的 Context。
URL 状态常被漏掉,但它天然可分享、可前进后退,写法上就是当普通 state 用:
const [params, setParams] = useSearchParams()
// 读:URL 就是唯一数据源,刷新、分享、后退都能还原
const keyword = params.get('q') ?? ''
const page = Number(params.get('page') ?? 1)
// 写:整体替换,别直接改 location
const next = new URLSearchParams(params)
next.set('q', value)
setParams(next, { replace: true }) // 搜索框用 replace,避免每敲一个字多一条历史
五、一套默认组合
给一条能直接执行的规则:
- 先分类 —— 按第一节的五个问题过一遍;
- 按类选归属 —— 服务端数据 → 请求库;URL 可推导 → 放 URL;表单 → 表单库;低频全局配置 → Context;复杂共享 → Zustand/Jotai/Redux;
- 最后才谈库 —— 同类库之间的差别远小于「分类分错」带来的代价。
再补两条经验:越全局的东西越要少,因为它的每一次变化都可能惊动整棵树;能就近就就近,共享是被逼出来的选择,不是默认设计。
服务端数据那一类,用请求库自带的缓存就够,不必再塞进 store:
// 以前:接口数据进 store,缓存失效、去重、重试全要手写
const { data } = useQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrders(userId),
staleTime: 30_000, // 30 秒内不重复请求
})
// store 里只留真正属于客户端的交互状态
const useUI = create((set) => ({ drawerOpen: false, toggle: () => set((s) => ({ drawerOpen: !s.drawerOpen })) }))
六、选库不决定成败
Context 不是全局 store。它适合低频、小范围的配置,高频变化的值会引起整棵子树重渲染。
用了 Redux 也不等于架构清晰——可预测性的代价是样板代码,小项目里它常常是净负担。
更常见的错误是把所有状态都集中管理。能就近就就近,越是全局的东西越要少。反过来也一样:不是所有数据都该进请求库,只有真值在服务端的才该进,纯客户端的交互态放进去反而别扭。
状态管理的难题八成不是选错库,而是把不该放在一起的状态塞进了同一个地方。
轻量方案的取舍可以拿 Zustand 当标尺——它把「不做什么」写在 README 里,正好用来对照自己的需求。