Zustand 全局状态管理
Zustand 的核心用法:用轻量 store 管理共享客户端状态、选择性订阅与可选持久化,并与 TanStack Query 的 server-state projection 做清晰 ownership 分工。
[!info] related notes
- 并列: TanStack Query 服务端状态
- 状态选型: React 状态管理方案选型
- 架构: 四层状态架构
- 认证边界: Cookie、Session 与 Token
- 实践: BodySense 项目 MOC
Zustand 全局状态管理
核心问题
Zustand 适合管理需要跨组件/跨页面共享的客户端状态,例如 UI 偏好、工作流状态、临时客户端 runtime state,以及某些需要统一协调的客户端凭据状态。
关键不是“数据能不能塞进 Zustand”,而是:
这份状态的 owner 是谁、生命周期多长、需要谁消费,以及冲突时谁裁决?
Zustand 是一个 state container,不会因为数据被放进去就自动成为业务 source of truth。
最小用法
interface UIState {
sidebarCollapsed: boolean;
setSidebarCollapsed: (value: boolean) => void;
}
export const useUIStore = create<UIState>()((set) => ({
sidebarCollapsed: false,
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
}));
组件中使用选择性订阅:
const collapsed = useUIStore((state) => state.sidebarCollapsed);
const setCollapsed = useUIStore((state) => state.setSidebarCollapsed);
Global 不等于 Durable,也不等于 Server Truth
Global store
= 客户端很多地方都能访问
Durable truth
= 系统最终认定的业务事实
两者不是一回事。
例如 /api/me 返回:
{ "id": "u1", "name": "Alice" }
即使为了认证流程方便把这份 user projection 放进 Zustand:
Zustand user = Alice
它的业务 ownership 仍属于服务器。若服务器已变为 Bob,而 Zustand 还保留 Alice,应重新同步服务器,而不是认为“Zustand 是全局状态,所以 Alice 才是真的”。
持久化:Storage Policy,不是 Ownership Rule
Zustand 的 persist 中间件适合保存安全且确实应该跨刷新/重启保留的客户端状态,例如主题或布局偏好:
const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
sidebarCollapsed: false,
setSidebarCollapsed: (value) => set({ sidebarCollapsed: value }),
}),
{
name: 'ui-preferences',
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
}),
},
),
);
不要把“需要 localStorage”当成选择 Zustand 的充分条件,也不要因为 Zustand 支持 persist 就默认把认证 bearer secrets 放进 localStorage。
特别是认证设计要单独评估 XSS、刷新恢复、Cookie/HttpOnly、服务端 Session Authority 等安全边界。见 Cookie、Session 与 Token。
BodySense 当前的典型模型是:
access token
→ 客户端内存状态
refresh credential
→ HttpOnly Cookie
server session authority
→ server/Redis
而不是在 Zustand persist 中把 access/refresh token 长期写入 localStorage。
选择性订阅
// ✅ 只订阅需要的字段
const user = useAuthStore((state) => state.user);
// ⚠️ 订阅整个 store 会让更多变化触发当前组件更新
const store = useAuthStore();
Zustand 的 selector 让消费者只关心自己需要的 slice;这属于客户端渲染/订阅优化,不改变状态 ownership。
与 TanStack Query 的分工
不要背:
来自 API → TanStack Query
需要 localStorage → Zustand
更准确的判断是:
Server-owned state
→ TanStack Query 通常最适合负责 fetch/cache/stale/refetch 生命周期
Shared client-owned state
→ Zustand / Context / Redux 等
Local component-only state
→ useState / useReducer
URL-addressable identity/state
→ Router / URL
例如:
会话列表 / HealthWorkspace
→ server-owned projection → TanStack Query
Sidebar collapsed
→ shared client presentation → Zustand
Dialog open
→ local component state → useState
conversationId in /conversations/:id
→ URL state
如果确实因为流程需要把 server-derived data 镜像到 Zustand,也要明确它是 projection,并定义刷新/失效策略,避免与 Query/服务器形成两个可独立修改的 truth source。
什么情况下 Global State 才值得存在
可以问:
1. 是否真的有多个远距离消费者?
2. 状态是否需要跨 route/component lifetime?
3. 由谁修改,修改规则是否集中?
4. 能否从 URL、Query data 或 props 派生?
5. 放全局后会不会形成第二份 server state?
能局部化或派生的状态,不要因为“以后可能会用”就提前全局化。
认证 Store 的特殊性
认证经常混合了几种不同 ownership:
accessToken
→ client runtime credential
user profile
→ server-derived projection
isAuthenticated
→ 通常是认证流程的客户端 projection
server session liveness
→ server authority
所以一个 authStore 可以为了流程协调把它们放在一起,但不代表这些字段属于同一 authority layer。
常见错误
把 API response 全复制进 Zustand
TanStack Query has user/workspace
+
Zustand has independently mutable copy
→ drift
除非有明确的 draft/transient projection 语义,否则避免双写。
用 persist 长期保存敏感 token,只因为实现方便
Storage 必须服从安全模型,不是服从状态库能力。
把局部 UI state 过度全局化
Dialog/Accordion 等只属于一个局部交互时,useState 往往更简单、更符合 lifetime。
在组件外调用 React hook
组件/Hook 外若需要 imperative 访问,可按 Zustand API 使用:
const { accessToken } = useAuthStore.getState();
而不是在普通函数中调用 React selector hook。
最短记忆
Zustand = shared client-state container
TanStack Query = server-state synchronization/cache
URL = navigation identity
useState = local interaction
Server = durable business authority
Persist 只是 storage policy;Global 只是 scope;
它们都不等于 source of truth。