Skip to main content

状态管理选型与状态分类

状态管理选型的第一步不是选库,而是把状态分类。分错类,用什么库都会难受。

一、按来源把状态分五类​

选错库的根因几乎都是没先分类。先问五个问题:

① 这份数据的真值在服务端吗? —— 是,就是服务端数据(用户资料、订单列表、商品价格)。它的特点是异步、会过期、需要缓存与重新验证。

② 它能不能从 URL 推导出来? —— 能,就是路由/URL 状态(搜索词、筛选条件、页码、当前 tab)。它的特点是应当可分享、可刷新保留、可前进后退。

③ 它是用户正在输入或操作的临时数据吗? —— 是,就是表单与交互态(输入框的值、正在拖拽的位置、草稿)。它的特点是高频变化、只属于当前这一个界面。

④ 它是全局生效的界面配置吗? —— 是,就是全局 UI 态(主题、语言、侧栏展开、权限开关)。它的特点是低频变化、读取点多。

⑤ 它需要被不相干的多个组件读写吗? —— 需要,就是共享领域态(购物车、编辑器选中集、多步流程的进度)。

// 反面例子:五类状态全塞进一个 store
const useStore = create((set) => ({
user: null, // ① 服务端数据
keyword: '', // ② 本该在 URL 里
draftTitle: '', // ③ 本该是组件本地 state
theme: 'light', // ④ 全局 UI 态(这个放这里还行)
cart: [], // ⑤ 共享领域态(这个也还行)
}))

五类的最佳归属完全不同。把它们塞进同一个 store,意味着你要自己实现缓存失效、URL 同步、表单校验——而这三件事各有专门的工具已经做好了。

先按来源分类,再决定放哪 —— 比先选库更重要服务端数据列表、详情、字典 → 请求库缓存URL 状态筛选、分页、路由参数 → search params客户端共享状态主题、语言、购物车 → Zustand / Context组件内状态输入框、展开收起 → useState 就近放表单状态校验、脏值、提交中 → 专用表单库不该进全局的能就近就就近,越全局越要少反模式:useEffect 里取数、到处加 useMemo、把一切塞进 Context —— 这三件事做对,比换库收益大。
图:按来源给状态分类,答案往往不是某个库,而是「就近放」

二、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,避免每敲一个字多一条历史

五、一套默认组合​

给一条能直接执行的规则:

  1. 先分类 —— 按第一节的五个问题过一遍;
  2. 按类选归属 —— 服务端数据 → 请求库;URL 可推导 → 放 URL;表单 → 表单库;低频全局配置 → Context;复杂共享 → Zustand/Jotai/Redux;
  3. 最后才谈库 —— 同类库之间的差别远小于「分类分错」带来的代价。

再补两条经验:越全局的东西越要少,因为它的每一次变化都可能惊动整棵树;能就近就就近,共享是被逼出来的选择,不是默认设计。

服务端数据那一类,用请求库自带的缓存就够,不必再塞进 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 里,正好用来对照自己的需求。