Zustand 状态设计:这份状态到底该不该进 store
一份状态摆在面前,第一个该问的不是「怎么存进 store」,而是「它到底该不该进 store」。
同样是一个字段,答案可能完全不同。当前登录用户,生命周期和整个应用一样长,跨十几个组件读,进 store 没有争议。列表页的筛选条件呢?它跟着那个页面走,还得能被分享、能刷新后还在,这种更该放 URL。刚请求回来的商品列表呢?它是服务端的副本,需要过期、重取、去重,交给 React Query 比自己在 store 里维护要省心得多。三个字段都可以塞进 Zustand,但塞进去之后维护成本差得远。
我给自己画的第一条线就是这个:store 装的应该是「客户端自己拥有、且要跨组件共享」的状态,不满足这两条的,先想别的去处。Zustand 好写,恰恰是它最容易被滥用的地方——API 少、不用 Provider 包裹,写一个全局字段的成本几乎为零:
1const useStore = create((set) => ({ 2 count: 0, 3 increase: () => set((state) => ({ count: state.count + 1 })), 4}))
成本低不代表随便放。越是容易写全局状态,越容易把 store 写成新的全局变量仓库。我接手过一个后台项目,一个 useAppStore 里塞了用户信息、主题、列表数据、弹窗开关、表单草稿、分页条件、上传进度。刚开始确实方便,任何组件想读什么直接取。半年后没人敢动这个文件,因为任何一个字段变了,都可能牵连一堆订阅它的组件,改哪个字段都像拆盲盒。
一个大 store 还是几个小 store
除非项目很小,否则不要把所有状态塞进一个 store。更该按领域拆:
1useAuthStore:登录用户、token、权限 2useThemeStore:主题、颜色模式 3useLayoutStore:侧边栏折叠、全局布局 4useEditorStore:编辑器状态
拆的标准不是文件数量,而是生命周期和业务归属。用户信息、主题的生命周期接近整个应用;编辑器状态只服务编辑页,用户根本没进那个页面时它就不该存在;列表筛选条件前面说过,更该进 URL。生命周期不同、归属不同,就不该混在一个 store 里同生共死。
判断「该不该再拆一个 store」时,我会问:这两组状态会不会一起被卸载?如果用户离开编辑页,编辑状态整块可以扔掉,而用户信息还得留着,那它们的边界就是清楚的,值得分开。反过来,如果两组状态永远一起出现、一起消失,硬拆成两个 store 只是增加协调成本。
Zustand 允许在一个 store 内部再用切片(slice)组织。领域之间关系紧但又想分文件维护时,切片比拆成多个独立 store 更合适:
1const createAuthSlice = (set) => ({ 2 user: null, 3 login: (user) => set({ user }), 4 logout: () => set({ user: null }), 5}) 6 7const createUiSlice = (set) => ({ 8 sidebarOpen: true, 9 toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), 10}) 11 12const useStore = create((...a) => ({ 13 ...createAuthSlice(...a), 14 ...createUiSlice(...a), 15}))
切片和多 store 不是对立的:跨页面、生命周期差异大的用多 store;同一块领域内部想分文件的用切片。别把切片当成「反正最后合成一个大 store」的借口,那又绕回万能 store 了。
选择器窄一点,重渲染少一点
订阅整个 store 是最常见的性能坑:
1const store = useUserStore()
这样 store 里任何字段变化,组件都可能重新渲染。更好的方式是只取需要的字段:
1const userName = useUserStore((state) => state.user?.name)
取多个值时要小心对象引用:
1const { user, logout } = useUserStore((state) => ({ 2 user: state.user, 3 logout: state.logout, 4}))
这段每次调用都返回一个新对象,Zustand 默认用 Object.is 比较,新对象引用永远不等,于是每次 store 变化都触发重渲染,等于白订阅。解决办法要么套 shallow 做浅比较,要么干脆拆成多个单值选择器:
1import { shallow } from 'zustand/shallow' 2 3const { user, logout } = useUserStore( 4 (state) => ({ user: state.user, logout: state.logout }), 5 shallow, 6) 7 8// 或者拆开,各订各的 9const user = useUserStore((state) => state.user) 10const logout = useUserStore((state) => state.logout)
Zustand 的性能优势很大程度来自精确订阅。选择器写宽了,优势就没了。这里也要按字段特点分别处理:字段少、都会一起变的,拆成单值选择器最直观;字段多、需要成组取的,用 shallow 一次取回更清爽,不必教条地非拆不可。
修改逻辑收进 store 的 action
我更倾向把修改逻辑放进 store 的 action,而不是在组件里直接 set 一堆字段:
1const useAuthStore = create((set) => ({ 2 user: null, 3 login: (user) => set({ user }), 4 logout: () => set({ user: null }), 5}))
组件只表达意图:
1const logout = useAuthStore((state) => state.logout)
这样以后登出要顺带清 token、清权限缓存、跳登录页,都有一个固定入口去改,不会漏。如果组件到处直接写 setState,业务规则就散在各处,哪天规则一变得满项目找。这也是一条边界:状态怎么变的规则属于 store,组件只负责「什么时候触发」。
action 里也可以放异步逻辑,但要分清楚该归哪边——如果这个异步是「拉服务端数据」,那多半又该交给 React Query 了;store 的 action 更适合承载客户端自己的状态编排,比如多步流程里推进到下一步、根据当前状态计算下一个状态。
能算出来的,别再存一份
派生状态是另一处容易越界的地方。能从已有状态算出来的,通常不要再单独存:
1const isAdmin = user.roles.includes('admin')
不要同时存:
1{ 2 user, 3 isAdmin: true, 4}
一旦存了两份,用户角色变化时 isAdmin 也得同步更新,漏一处就出 bug,而且这种 bug 特别隐蔽——数据对不上,但没人报错。大多数权限判断、状态标志、数量统计都可以直接算,算的成本远低于维护两份数据一致性的成本。
只有当派生计算确实很贵、或者需要缓存历史结果时,才考虑存一份。真要存,也建议用选择器里的记忆化而不是往 state 里塞字段,比如配合前面提到的 shallow 或者外部 memo,把「贵」这件事和「一致性」这件事分开处理。
持久化:先问会不会过期
Zustand 的 persist 很方便:
1persist( 2 (set) => ({ 3 theme: 'light', 4 setTheme: (theme) => set({ theme }), 5 }), 6 { name: 'app-theme' }, 7)
但不是所有全局状态都该持久化。判断标准还是那条:这份数据会不会过期。主题、语言、用户偏好、草稿,这些用户自己拥有、也不怕旧,适合持久化。权限缓存、临时弹窗状态、过期接口数据、高敏感 token,这些要么会变、要么是服务端说了算、要么根本不该落地,不该持久化。
尤其是权限和用户信息。把一份旧权限长期存在 localStorage,用户角色在后台被改了,前端还照旧权限渲染菜单,轻则显示错、重则让用户看到不该看的入口,非常危险。这类状态宁可每次启动重新拉,也不要图省事持久化。
如果确实要持久化,还得设计版本迁移,否则改了字段结构,老用户本地那份旧数据会直接把新代码搞崩:
1persist(config, { 2 name: 'editor', 3 version: 2, 4 migrate: (state, version) => { 5 if (version === 1) { 6 return { ...state, newField: '' } 7 } 8 return state 9 }, 10})
本地存储里的旧数据不会因为你改了代码就自动变新。persist 还有个 partialize 选项也很实用——它让你只挑一部分字段落地,而不是把整个 store 全写进 localStorage:
1persist(config, { 2 name: 'app', 3 partialize: (state) => ({ theme: state.theme, lang: state.lang }), 4})
这其实是把「哪些该存、哪些不该存」的取舍用代码固化下来了,比事后靠自觉记住哪些字段不能落地要可靠。
中间件按需上,别一开始就套一层
Zustand 的能力大多靠中间件叠加,但每个中间件都是一层包裹,加之前也该问「这个 store 真需要它吗」。我常用的就三个,各解决一个具体问题。
devtools 让 store 的变更能在 Redux DevTools 里看到时间线,调状态流转的 bug 时很省事:
1import { devtools } from 'zustand/middleware' 2 3const useStore = create(devtools((set) => ({ 4 count: 0, 5 inc: () => set((s) => ({ count: s.count + 1 }), false, 'inc'), 6})))
set 的第三个参数是 action 名,标上之后 DevTools 里每一步都有名字,不然全是匿名的 anonymous,回放时根本分不清哪步干了什么。这属于「给 action 一个固定入口」之后顺手能拿到的好处——入口清楚了,可观测性也跟着来了。
immer 中间件适合状态嵌套深、又不想到处写展开运算符的场景。原生 Zustand 更新嵌套字段得一层层 ... 铺回去,深了很容易漏一层导致引用没变、组件不更新:
1import { immer } from 'zustand/middleware/immer' 2 3const useStore = create(immer((set) => ({ 4 filters: { page: 1, sort: {} }, 5 setPage: (page) => set((s) => { s.filters.page = page }), 6})))
有了 immer 就能直接「改」草稿,它替你产出不可变的新状态。但它也不是白来的——多一层依赖、多一点心智成本,浅层状态的 store 我通常不套,展开运算符够用就够用。中间件是按需叠的,不是开局就套满。
不订阅也能读:transient 更新
有些状态变化很频繁——鼠标位置、滚动进度、拖拽偏移量。如果用常规选择器订阅,每次变化都触发一次 React 重渲染,高频场景下会卡。Zustand 允许绕开 React 的订阅,直接用 subscribe 拿到变化,在回调里手动操作(比如直接改 DOM、更新 canvas):
1useStore.subscribe( 2 (state) => state.scrollY, 3 (scrollY) => { 4 progressBar.style.width = `${scrollY}%` 5 }, 6)
这条路把「状态存在哪」和「谁因为它重渲染」解耦了:值照样进 store 供别处读,但这个高频消费者不走 React 渲染。它是个逃生口,不是常规写法——绝大多数状态还是该老老实实用选择器订阅,只有确认某个字段变化频率高到影响性能,才考虑退到这一层手动处理。
测起来方不方便,也能照出设计好不好
一个 store 好不好测,能反过来照出它的职责划得对不对。因为 Zustand 的 store 就是个普通函数创建的对象,action 是纯粹的状态转换,测试时不用挂载组件,直接调用就行:
1test('logout 清掉用户', () => { 2 useAuthStore.getState().login({ name: 'a' }) 3 useAuthStore.getState().logout() 4 expect(useAuthStore.getState().user).toBeNull() 5})
这里就能验证前面那些取舍:如果登出的逻辑散在各个组件里,你根本没法这样测;正因为修改逻辑收进了 action,它才变成一个可以脱离 UI 单独验证的纯函数。反过来,如果一个 store 里塞满了服务端数据、异步请求、时序依赖,测试要 mock 一大堆东西才跑得起来,那多半就是职责没分清——该交给 React Query 的东西赖在了 store 里。
顺带一提,测试里记得在每个用例前重置 store 状态,否则用例之间会互相污染。这也是「小而清楚的 store」的又一个好处:状态越少、越聚焦,重置和隔离就越简单。
服务端数据交给专门的工具
列表、详情、接口缓存,通常更适合 React Query 或 SWR。到 2023 年初,TanStack Query(原 React Query)已经是这一层的成熟选择,团队里新项目基本默认它。
如果把接口数据塞进 Zustand,你就得自己处理 stale、refetch、retry、去重、失效、mutation 后的缓存更新——这些正是 React Query 帮你做掉的事。除非你有明确理由要自己实现一遍,否则不如各司其职:服务端状态给 React Query,客户端状态给 Zustand。
Zustand 更适合的是客户端自己拥有的状态:
- 用户偏好、UI 布局
- 跨组件的临时状态
- 复杂编辑器状态
- 多步骤流程状态
这两者也不是老死不相往来。常见做法是 React Query 管数据、Zustand 管「用户在这份数据上做了什么选择」,比如选中了哪几行、当前在第几步。数据和交互意图分层,各自的职责就清楚了。
写 store 前的几个判断
真正下笔写一个 store 字段前,我会先过一遍这几个问题:
- 这个状态是不是真的需要跨组件共享,还是一个组件内部的
useState就够 - 它的生命周期是不是接近所在领域,还是随某个页面来去
- 它是不是更该放 URL、表单库或者 React Query
- 选择器够不够窄,会不会因为返回新对象白订阅
- 修改逻辑有没有收进 action 这个固定入口
- 有没有存了本可以直接算的派生状态
- 要持久化的话,有没有过期风险、有没有版本迁移
Zustand 很轻,但状态本身不轻。回到开头那三个字段:登录用户放 store 没有争议,筛选条件该去 URL,商品列表该交给 React Query——store 该装的,从来只是「这份状态是不是客户端自己拥有、且真的要跨组件共享」这一件事,答案是否,就该去别的地方,而不是因为 Zustand 好写就顺手塞进来。