Zustand 状态管理:复杂应用程序的模式
React 的内置状态原语(useState、useReducer、useContext)适用于本地和共享 UI 状态,但它们在规模上会遇到摩擦:上下文提供程序在每次状态更改时重新渲染整个树,useReducer 需要详细的操作样板,并且 prop-drilling 变得难以管理。 Redux 解决了这个问题,但代价是大量的样板文件和精神开销。
Zustand 占据了完美的中间地带:2.9KB,无提供程序,无样板,TypeScript 优先,并且与 React DevTools 兼容。它擅长处理客户端应用程序状态(购物车、侧边栏状态、多步骤表单数据、UI 首选项),而 TanStack Query 则处理服务器状态。本指南涵盖了生产应用程序的 Zustand v5 模式。
要点
- Zustand 用于仅限客户端状态;使用 TanStack 查询服务器状态 - 不要重叠他们的职责
- 不需要提供者包装器 - 商店是模块级单例,可以在任何地方访问
- 对大型存储使用切片模式:将多个切片创建者组合成一个
create()调用immer中间件在操作中启用直接突变语法(无扩展运算符)persist中间件,带有localStorage或sessionStorage用于跨会话状态devtools中间件与 Redux DevTools Extension 集成以进行时间旅行调试- 具有
useShallow的选择器可防止浅层相等对象不必要的重新渲染- 切勿将服务器数据存储在 Zustand 中 - 失效成为您的问题
基本商店
// src/lib/stores/ui.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface UiState {
// State
sidebarOpen: boolean;
theme: 'light' | 'dark' | 'system';
commandPaletteOpen: boolean;
activeModal: string | null;
// Actions
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setTheme: (theme: UiState['theme']) => void;
openModal: (modalId: string) => void;
closeModal: () => void;
toggleCommandPalette: () => void;
}
export const useUiStore = create<UiState>()(
devtools(
(set) => ({
sidebarOpen: true,
theme: 'system',
commandPaletteOpen: false,
activeModal: null,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar'),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'setSidebarOpen'),
setTheme: (theme) =>
set({ theme }, false, 'setTheme'),
openModal: (modalId) =>
set({ activeModal: modalId }, false, 'openModal'),
closeModal: () =>
set({ activeModal: null }, false, 'closeModal'),
toggleCommandPalette: () =>
set(
(state) => ({ commandPaletteOpen: !state.commandPaletteOpen }),
false,
'toggleCommandPalette'
),
}),
{ name: 'ui-store' }
)
);
// Usage in components — select only what you need
function Sidebar() {
const isOpen = useUiStore((state) => state.sidebarOpen);
const toggle = useUiStore((state) => state.toggleSidebar);
return (
<aside className={cn('sidebar', isOpen && 'sidebar--open')}>
<button onClick={toggle}>Toggle</button>
</aside>
);
}
持久的购物车商店
// src/lib/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
licenseType: 'one-time' | 'subscription';
}
interface CartState {
items: CartItem[];
couponCode: string | null;
couponDiscount: number;
// Computed (derived state — use selectors, not stored values)
// subtotal, total, itemCount — computed in selectors
// Actions
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
applyCoupon: (code: string, discount: number) => void;
removeCoupon: () => void;
clearCart: () => void;
}
export const useCart = create<CartState>()(
devtools(
persist(
immer((set) => ({
items: [],
couponCode: null,
couponDiscount: 0,
addItem: (newItem) =>
set((state) => {
const existing = state.items.find((i) => i.productId === newItem.productId);
if (existing) {
existing.quantity += 1; // Immer allows direct mutation
} else {
state.items.push({ ...newItem, quantity: 1 });
}
}),
removeItem: (productId) =>
set((state) => {
state.items = state.items.filter((i) => i.productId !== productId);
}),
updateQuantity: (productId, quantity) =>
set((state) => {
if (quantity <= 0) {
state.items = state.items.filter((i) => i.productId !== productId);
} else {
const item = state.items.find((i) => i.productId === productId);
if (item) item.quantity = quantity;
}
}),
applyCoupon: (code, discount) =>
set((state) => {
state.couponCode = code;
state.couponDiscount = discount;
}),
removeCoupon: () =>
set((state) => {
state.couponCode = null;
state.couponDiscount = 0;
}),
clearCart: () =>
set((state) => {
state.items = [];
state.couponCode = null;
state.couponDiscount = 0;
}),
})),
{
name: 'ecosire-cart',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
// Only persist items and coupon, not derived state
items: state.items,
couponCode: state.couponCode,
couponDiscount: state.couponDiscount,
}),
}
),
{ name: 'cart-store' }
)
);
// Selectors — computed from state, not stored
export const selectSubtotal = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectTotal = (state: CartState) => {
const subtotal = selectSubtotal(state);
return subtotal * (1 - state.couponDiscount);
};
export const selectItemCount = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
大型商店的切片图案
对于复杂的商店,分为切片并组合:
// src/lib/stores/slices/filters.slice.ts
import type { StateCreator } from 'zustand';
export interface FiltersSlice {
search: string;
status: string;
page: number;
sortField: string;
sortDirection: 'asc' | 'desc';
setSearch: (search: string) => void;
setStatus: (status: string) => void;
setPage: (page: number) => void;
setSort: (field: string, direction: 'asc' | 'desc') => void;
resetFilters: () => void;
}
const initialFilters = {
search: '',
status: 'all',
page: 1,
sortField: 'createdAt',
sortDirection: 'desc' as const,
};
export const createFiltersSlice: StateCreator<FiltersSlice> = (set) => ({
...initialFilters,
setSearch: (search) => set({ search, page: 1 }), // Reset page on search
setStatus: (status) => set({ status, page: 1 }),
setPage: (page) => set({ page }),
setSort: (field, direction) => set({ sortField: field, sortDirection: direction }),
resetFilters: () => set(initialFilters),
});
// src/lib/stores/slices/selection.slice.ts
import type { StateCreator } from 'zustand';
export interface SelectionSlice {
selectedIds: Set<string>;
selectAll: boolean;
toggleSelection: (id: string) => void;
selectAllItems: (ids: string[]) => void;
clearSelection: () => void;
}
export const createSelectionSlice: StateCreator<SelectionSlice> = (set) => ({
selectedIds: new Set(),
selectAll: false,
toggleSelection: (id) =>
set((state) => {
const next = new Set(state.selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedIds: next, selectAll: false };
}),
selectAllItems: (ids) =>
set({ selectedIds: new Set(ids), selectAll: true }),
clearSelection: () =>
set({ selectedIds: new Set(), selectAll: false }),
});
// src/lib/stores/contacts-table.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createFiltersSlice, type FiltersSlice } from './slices/filters.slice';
import { createSelectionSlice, type SelectionSlice } from './slices/selection.slice';
type ContactsTableStore = FiltersSlice & SelectionSlice;
export const useContactsTableStore = create<ContactsTableStore>()(
devtools(
(...args) => ({
...createFiltersSlice(...args),
...createSelectionSlice(...args),
}),
{ name: 'contacts-table-store' }
)
);
防止不必要的重新渲染
当选定值更改时,Zustand 会重新渲染组件(通过对象的引用)。
// PROBLEM: new object created on every render → infinite re-renders
const { search, page } = useContactsTableStore((state) => ({
search: state.search,
page: state.page,
}));
// SOLUTION 1: Select primitives individually
const search = useContactsTableStore((state) => state.search);
const page = useContactsTableStore((state) => state.page);
// SOLUTION 2: Use useShallow for object selections
import { useShallow } from 'zustand/react/shallow';
const { search, page } = useContactsTableStore(
useShallow((state) => ({ search: state.search, page: state.page }))
);
// SOLUTION 3: Select an action (actions never change reference)
const setSearch = useContactsTableStore((state) => state.setSearch);
Zustand + TanStack 查询:清晰分离
关键规则:Zustand 代表客户端状态,TanStack Query 代表服务器状态——永不重叠。
// WRONG: storing server data in Zustand
const useContactsStore = create(() => ({
contacts: [], // Server data in Zustand — invalidation nightmare
fetchContacts: async () => { /* fetch and set */ },
}));
// CORRECT: TanStack Query for server data, Zustand for UI state
// TanStack Query
const { data: contacts } = useContacts({ page, limit, search });
// Zustand — only UI-specific state
const { search, page, setSearch, setPage } = useContactsTableStore();
查询键对 Zustand 状态变化做出反应,因此过滤/排序就可以了:
function ContactsPage() {
// UI state from Zustand
const search = useContactsTableStore((s) => s.search);
const page = useContactsTableStore((s) => s.page);
const status = useContactsTableStore((s) => s.status);
// Server state from TanStack Query, driven by Zustand state
const { data, isLoading } = useContacts({ search, page, status, limit: 20 });
return <ContactsTable data={data?.data ?? []} loading={isLoading} />;
}
多步骤表单存储
// src/lib/stores/onboarding.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface OnboardingData {
company: { name: string; size: string; industry: string };
contact: { name: string; email: string; phone: string };
requirements: { erp: string; modules: string[]; budget: string };
}
interface OnboardingStore {
currentStep: number;
completed: Set<number>;
data: Partial<OnboardingData>;
setStep: (step: number) => void;
updateStepData: <K extends keyof OnboardingData>(
step: K,
data: OnboardingData[K]
) => void;
markStepComplete: (step: number) => void;
reset: () => void;
}
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set) => ({
currentStep: 0,
completed: new Set(),
data: {},
setStep: (step) => set({ currentStep: step }),
updateStepData: (key, stepData) =>
set((state) => ({
data: { ...state.data, [key]: stepData },
})),
markStepComplete: (step) =>
set((state) => ({
completed: new Set([...state.completed, step]),
})),
reset: () => set({ currentStep: 0, completed: new Set(), data: {} }),
}),
{
name: 'onboarding-progress',
storage: createJSONStorage(() => sessionStorage),
// Serialize Set properly
partialize: (state) => ({
...state,
completed: Array.from(state.completed),
}),
merge: (persisted: any, current) => ({
...current,
...persisted,
completed: new Set(persisted.completed ?? []),
}),
}
)
);
测试 Zustand 商店
// src/lib/stores/__tests__/cart.store.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCart, selectSubtotal, selectTotal } from '../cart';
describe('Cart Store', () => {
beforeEach(() => {
// Reset store state between tests
useCart.setState({
items: [],
couponCode: null,
couponDiscount: 0,
});
});
it('adds items to the cart', () => {
const { addItem } = useCart.getState();
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
const { items } = useCart.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(1);
});
it('increments quantity when same product added again', () => {
const { addItem } = useCart.getState();
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
addItem({ id: '1', productId: 'prod_1', name: 'Odoo Module', price: 249 });
const { items } = useCart.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(2);
});
it('calculates subtotal correctly', () => {
useCart.setState({
items: [
{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 2, licenseType: 'one-time' },
{ id: '2', productId: 'p2', name: 'B', price: 50, quantity: 1, licenseType: 'one-time' },
],
});
expect(selectSubtotal(useCart.getState())).toBe(250);
});
it('applies coupon discount', () => {
useCart.setState({
items: [{ id: '1', productId: 'p1', name: 'A', price: 100, quantity: 1, licenseType: 'one-time' }],
couponCode: 'SAVE20',
couponDiscount: 0.2,
});
expect(selectTotal(useCart.getState())).toBe(80);
});
});
常见问题
我还需要 Zustand 的 Context API 吗?
很少。 Zustand 的模块级单例取代了大多数 Context API 用例。 Context 的主要剩余用例是依赖项注入 - 将特定存储实例传递到子树(对于测试或需要多个独立存储实例时很有用)。为此,Zustand 提供了 createStore() + useStore() ,它们与商店实例的 React 上下文一起使用。
Zustand 与 Redux Toolkit 相比如何?
Redux Toolkit 具有更多的结构(切片、reducers、actions)和更好的 DevTools 集成,用于复杂的时间旅行调试。 Zustand 的样板文件较少,捆绑包较小(Redux + RTK 为 2.9KB,而 Redux + RTK 约为 30KB),并且不需要提供程序。对于大多数应用程序来说,Zustand 更简单且足够。如果您有一个大型团队受益于强制模式或需要高级 DevTools 功能,请选择 Redux Toolkit。
如何将 Zustand 与 Next.js App Router SSR 结合使用?
Zustand 存储是模块级单例——对于客户端组件来说是安全的,但不能在服务器组件中使用(没有浏览器 API,没有窗口)。仅初始化 'use client' 组件中的存储。对于初始数据的 SSR 水合作用,请使用 TanStack Query 的 HydrationBoundary 作为服务器数据。对于需要在导航中保持不变的仅限客户端状态(购物车、UI 首选项),请将 persist 中间件与 localStorage 结合使用。
什么是 immer 中间件以及何时应该使用它?
immer 中间件用 Immer 包装了 Zustand 的 set 函数,允许您在操作 (state.items.push(item)) 中编写变异代码,而这些代码实际上在幕后是不可变的。当您的嵌套状态更新难以使用扩展语法时,例如更新深度嵌套的属性或使用数组,请使用它。它为您的捆绑包增加了大约 6KB — 对于复杂的商店来说这是值得的,对于简单的商店来说则不必要。
如何避免 Zustand 操作中的陈旧关闭?
Zustand 将当前状态传递给 set 回调,因此在读取操作内的状态时始终使用函数形式: set((state) => ({ count: state.count + 1 })) 而不是从闭包中捕获 state.count 。对于在 await 之后读取状态的异步操作,请在使用时调用 getState(),而不是在等待之前在闭包中捕获状态。
后续步骤
Zustand 的最小占用空间和可组合切片模式使其成为任何规模的 React 应用程序中客户端状态的正确工具。与服务器状态的 TanStack 查询配合使用,您将拥有一个干净、可扩展的状态管理架构,并且没有样板开销。
ECOSIRE 使用 Zustand + TanStack Query 构建 React 应用程序作为标准状态管理堆栈 - 为复杂的管理面板、客户门户和电子商务流程提供服务。 探索我们的前端工程服务 了解我们如何构建可扩展的 React 应用程序。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
API Integration Patterns for OpenClaw Agents
Technical patterns for integrating OpenClaw AI agents with REST APIs, GraphQL, webhooks, and enterprise systems. Covers authentication, error handling, rate limiting, and testing.