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 には冗長なアクションのボイラープレートが必要になり、プロップドリルは管理できなくなります。 Redux はこれを解決しますが、定型文と精神的なオーバーヘッドが大幅に発生します。

Zustand は、2.9 KB、プロバイダーなし、ボイラープレートなし、TypeScript ファースト、React DevTools との互換性という完璧な中間点を占めています。 TanStack Query はサーバーの状態を処理しながら、クライアント側のアプリケーションの状態 (ショッピング カート、サイドバーの状態、マルチステップ フォーム データ、UI 設定) に優れています。このガイドでは、運用アプリケーション向けの Zustand v5 パターンについて説明します。

重要なポイント

  • Zustand はクライアント専用状態です。サーバーの状態には TanStack Query を使用します。責任を重複させないでください。
  • プロバイダー ラッパーは必要ありません - ストアはモジュール レベルのシングルトンであり、どこからでもアクセスできます
  • 大規模なストアにはスライス パターンを使用します。複数のスライス クリエーターを 1 つの create() 呼び出しにまとめます。
  • immer ミドルウェアにより、アクション内で直接変更構文が有効になります (スプレッド演算子なし)
  • クロスセッション状態用の localStorage または sessionStorage を備えた persist ミドルウェア
  • 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 はストア インスタンスの React コンテキストで動作する createStore() + useStore() を提供します。

Zustand と Redux Toolkit を比較するとどうですか?

Redux Toolkit には、より多くの構造 (スライス、リデューサー、アクション) があり、複雑なタイムトラベル デバッグのための DevTools の統合が強化されています。 Zustand はボイラープレートが少なく、バンドルも小さく (Redux + RTK の場合は 2.9KB に対して約 30KB)、プロバイダーは必要ありません。ほとんどのアプリケーションでは、Zustand の方がシンプルで十分です。大規模なチームが強制パターンの恩恵を受ける場合、または高度な DevTools 機能が必要な場合は、Redux Toolkit を選択してください。

Next.js App Router SSR で Zustand を使用するにはどうすればよいですか?

Zustand ストアはモジュール レベルのシングルトンです。クライアント コンポーネントにとっては安全ですが、サーバー コンポーネントでは使用できません (ブラウザ API やウィンドウはありません)。 'use client' コンポーネントのみでストアを初期化します。初期データの SSR ハイドレーションには、サーバー データに TanStack Query の HydrationBoundary を使用します。ナビゲーション間で保持する必要があるクライアントのみの状態 (カート、UI 設定) の場合は、localStorage とともに persist ミドルウェアを使用します。

immer ミドルウェアとは何ですか?いつ使用する必要がありますか?

immer ミドルウェアは、Zustand の set 関数を Immer でラップし、内部では実際には不変である変更コード (state.items.push(item)) をアクション内に記述できるようにします。これは、深くネストされたプロパティの更新や配列の操作など、スプレッド構文では厄介なネストされた状態の更新がある場合に使用します。バンドルに最大 6 KB が追加されます。複雑なストアには価値がありますが、単純なストアには不要です。

Zustand アクションでの古いクロージャを回避するにはどうすればよいですか?

Zustand は現在の状態を set コールバックに渡すため、アクション内の状態を読み取るときは、クロージャから state.count をキャプチャする代わりに、常に関数形式 set((state) => ({ count: state.count + 1 })) を使用してください。 await の後に状態を読み取る非同期アクションの場合、await の前にクロージャで状態をキャプチャするのではなく、使用時点で getState() を呼び出します。


次のステップ

Zustand の最小のフットプリントと構成可能なスライス パターンにより、Zustand は、あらゆるサイズの React アプリケーションのクライアント状態に適したツールになります。サーバー状態の TanStack Query と組み合わせることで、定型的なオーバーヘッドのない、クリーンでスケーラブルな状態管理アーキテクチャが得られます。

ECOSIRE は、標準の状態管理スタックとして Zustand + TanStack Query を使用して React アプリケーションを構築し、複雑な管理パネル、顧客ポータル、電子商取引フローに対応します。 フロントエンド エンジニアリング サービスを探索 して、スケーラブルな React アプリケーションを構築する方法を学びましょう。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット