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 ERP、Shopify 电子商务、AI 智能体、Power BI 分析、GoHighLevel 自动化以及企业软件最佳实践。我们的指南帮助企业做出明智的技术决策。
相关文章
NestJS 11 企业 API 模式
掌握 NestJS 11 企业模式:用于可扩展后端系统的防护、拦截器、管道、多租户和生产就绪 API 设计。
Next.js 16 应用程序路由器:生产模式和陷阱
生产就绪的 Next.js 16 个 App Router 模式:服务器组件、缓存策略、元数据 API、错误边界和要避免的性能陷阱。
OpenClaw 代理的 API 集成模式
将 OpenClaw AI 代理与 REST API、GraphQL、webhooks 和企业系统集成的技术模式。涵盖身份验证、错误处理、速率限制和测试。