Zustand State Management: Patterns for Complex Apps

Master Zustand state management for React apps. Covers slices, middleware, persistence, devtools, TypeScript patterns, and integration with TanStack Query for complex application state.

E
ECOSIRE Research and Development Team
|2026年3月19日8 分钟阅读1.7k 字数|

Zustand 状态管理:复杂应用程序的模式

React 的内置状态原语(useStateuseReduceruseContext)适用于本地和共享 UI 状态,但它们在规模上会遇到摩擦:上下文提供程序在每次状态更改时重新渲染整个树,useReducer 需要详细的操作样板,并且 prop-drilling 变得难以管理。 Redux 解决了这个问题,但代价是大量的样板文件和精神开销。

Zustand 占据了完美的中间地带:2.9KB,无提供程序,无样板,TypeScript 优先,并且与 React DevTools 兼容。它擅长处理客户端应用程序状态(购物车、侧边栏状态、多步骤表单数据、UI 首选项),而 TanStack Query 则处理服务器状态。本指南涵盖了生产应用程序的 Zustand v5 模式。

要点

  • Zustand 用于仅限客户端状态;使用 TanStack 查询服务器状态 - 不要重叠他们的职责
  • 不需要提供者包装器 - 商店是模块级单例,可以在任何地方访问
  • 对大型存储使用切片模式:将多个切片创建者组合成一个 create() 调用
  • immer 中间件在操作中启用直接突变语法(无扩展运算符)
  • persist 中间件,带有 localStoragesessionStorage 用于跨会话状态
  • 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 应用程序。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天