T-CREATOR

Zustand のベストプラクティス 10 選:実戦で学ぶ設計と保守のコツ

Zustand のベストプラクティス 10 選:実戦で学ぶ設計と保守のコツ

実戦的なプロジェクトで Zustand を導入する際、多くの開発者が直面するのが「どうやって設計すれば保守しやすいコードになるのか?」という疑問です。

シンプルで軽量な Zustand ですが、だからこそ設計の自由度が高く、プロジェクトが成長するにつれて技術的負債が蓄積されやすい側面もあります。

今回は、実際のプロジェクトで培われた 10 のベストプラクティスを、設計・実装・運用の各フェーズに分けてご紹介します。これらの知見を活用することで、スケーラブルで保守性の高い Zustand アプリケーションを構築できるでしょう。

設計フェーズのベストプラクティス

ベストプラクティス 1:ストア分割戦略の選択

大規模なアプリケーションにおいて、単一の巨大なストアは保守性を著しく損ないます。適切な分割戦略を選択することが重要です。

ドメイン駆動分割(推奨)

typescript// ユーザー関連のストア
interface UserState {
  currentUser: User | null;
  preferences: UserPreferences;
  setUser: (user: User) => void;
  updatePreferences: (
    preferences: Partial<UserPreferences>
  ) => void;
}

export const useUserStore = create<UserState>((set) => ({
  currentUser: null,
  preferences: {
    theme: 'light',
    language: 'ja',
    notifications: true,
  },
  setUser: (user) => set({ currentUser: user }),
  updatePreferences: (preferences) =>
    set((state) => ({
      preferences: { ...state.preferences, ...preferences },
    })),
}));

// プロダクト関連のストア
interface ProductState {
  products: Product[];
  categories: Category[];
  filters: ProductFilters;
  loadProducts: () => Promise<void>;
  updateFilters: (filters: Partial<ProductFilters>) => void;
}

export const useProductStore = create<ProductState>(
  (set, get) => ({
    products: [],
    categories: [],
    filters: {
      category: null,
      priceRange: [0, 10000],
      sortBy: 'name',
    },
    loadProducts: async () => {
      const { filters } = get();
      const products = await fetchProducts(filters);
      set({ products });
    },
    updateFilters: (newFilters) =>
      set((state) => ({
        filters: { ...state.filters, ...newFilters },
      })),
  })
);

ドメイン駆動分割により、関連する状態とロジックを 1 箇所にまとめることで、機能ごとの独立性が保たれます。

分割基準の判断表

条件単一ストア分割ストア
状態間の依存関係強い弱い
更新頻度同期的独立的
チーム担当範囲重複分離
テストの複雑度許容範囲分離したい

ベストプラクティス 2:状態の正規化設計

関連データを効率的に管理するため、正規化されたデータ構造を採用しましょう。

非正規化(アンチパターン)

typescript// ❌ 悪い例:非正規化されたデータ
interface BadState {
  posts: {
    id: string;
    title: string;
    author: {
      id: string;
      name: string;
      email: string;
    };
    comments: {
      id: string;
      content: string;
      author: {
        id: string;
        name: string;
        email: string;
      };
    }[];
  }[];
}

正規化された設計(推奨)

typescript// ✅ 良い例:正規化されたデータ構造
interface NormalizedState {
  entities: {
    users: Record<string, User>;
    posts: Record<string, Post>;
    comments: Record<string, Comment>;
  };
  collections: {
    postIds: string[];
    commentsByPost: Record<string, string[]>;
  };
  ui: {
    selectedPostId: string | null;
    isLoading: boolean;
  };
}

const useAppStore = create<NormalizedState & Actions>(
  (set, get) => ({
    entities: {
      users: {},
      posts: {},
      comments: {},
    },
    collections: {
      postIds: [],
      commentsByPost: {},
    },
    ui: {
      selectedPostId: null,
      isLoading: false,
    },

    // 正規化されたデータの更新
    addPost: (post: Post) =>
      set((state) => ({
        entities: {
          ...state.entities,
          users: {
            ...state.entities.users,
            [post.authorId]: post.author,
          },
          posts: {
            ...state.entities.posts,
            [post.id]: post,
          },
        },
        collections: {
          ...state.collections,
          postIds: [...state.collections.postIds, post.id],
        },
      })),

    // セレクターで非正規化されたデータを取得
    getPostWithAuthor: (postId: string) => {
      const state = get();
      const post = state.entities.posts[postId];
      const author = post
        ? state.entities.users[post.authorId]
        : null;
      return post && author ? { ...post, author } : null;
    },
  })
);

正規化により、データの一貫性が保たれ、更新時のパフォーマンスが向上します。

ベストプラクティス 3:アクションの命名規則統一

チーム開発において、一貫したアクション命名規則は可読性と保守性を大幅に向上させます。

推奨命名パターン

typescriptinterface StoreActions {
  // 基本操作:動詞 + 名詞
  setUser: (user: User) => void;
  updateUser: (updates: Partial<User>) => void;
  deleteUser: (userId: string) => void;
  clearUsers: () => void;

  // 非同期操作:動詞 + 名詞 + Async(オプション)
  loadUsers: () => Promise<void>;
  saveUser: (user: User) => Promise<void>;
  fetchUserProfile: (userId: string) => Promise<void>;

  // UI状態:set + UI要素名
  setModalOpen: (isOpen: boolean) => void;
  setSelectedTab: (tab: string) => void;
  setLoadingState: (isLoading: boolean) => void;

  // 複合操作:具体的な業務動詞
  loginUser: (
    credentials: LoginCredentials
  ) => Promise<void>;
  logoutUser: () => void;
  registerUser: (userData: RegisterData) => Promise<void>;
}

const useAuthStore = create<AuthState & StoreActions>(
  (set, get) => ({
    // 状態
    currentUser: null,
    isAuthenticated: false,
    isLoading: false,

    // 基本操作
    setUser: (user) =>
      set({
        currentUser: user,
        isAuthenticated: !!user,
      }),

    updateUser: (updates) =>
      set((state) => ({
        currentUser: state.currentUser
          ? { ...state.currentUser, ...updates }
          : null,
      })),

    clearUsers: () =>
      set({
        currentUser: null,
        isAuthenticated: false,
      }),

    // 非同期操作
    loginUser: async (credentials) => {
      set({ isLoading: true });
      try {
        const user = await authAPI.login(credentials);
        get().setUser(user);
      } catch (error) {
        console.error('Login failed:', error);
        throw error;
      } finally {
        set({ isLoading: false });
      }
    },

    logoutUser: () => {
      authAPI.logout();
      get().clearUsers();
    },
  })
);

命名規則を統一することで、新しいチームメンバーでも直感的にアクションの役割を理解できます。

実装フェーズのベストプラクティス

ベストプラクティス 4:セレクター活用によるパフォーマンス最適化

Zustand のセレクター機能を適切に活用することで、不要な再レンダリングを防止できます。

部分的な状態選択

typescript// ❌ 悪い例:全体状態を取得
const BadComponent = () => {
  const store = useStore(); // 全体が変更されるたびに再レンダリング
  return <div>{store.user.name}</div>;
};

// ✅ 良い例:必要な部分のみ選択
const GoodComponent = () => {
  const userName = useStore((state) => state.user.name);
  return <div>{userName}</div>;
};

計算結果のメモ化

typescript// メモ化されたセレクター関数
const selectFilteredProducts = (state: ProductState) => {
  const { products, filters } = state;
  return products.filter((product) => {
    if (
      filters.category &&
      product.category !== filters.category
    ) {
      return false;
    }
    if (
      product.price < filters.priceRange[0] ||
      product.price > filters.priceRange[1]
    ) {
      return false;
    }
    return true;
  });
};

// shallow比較でのパフォーマンス最適化
import { shallow } from 'zustand/shallow';

const ProductList = () => {
  const filteredProducts = useProductStore(
    selectFilteredProducts,
    shallow
  );
  const isLoading = useProductStore(
    (state) => state.isLoading
  );

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      {filteredProducts.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
};

カスタムフックでのセレクター抽象化

typescript// 再利用可能なセレクターフック
export const useProductFilters = () => {
  return useProductStore(
    (state) => ({
      filters: state.filters,
      updateFilters: state.updateFilters,
      clearFilters: state.clearFilters,
    }),
    shallow
  );
};

export const useFilteredProducts = () => {
  return useProductStore(selectFilteredProducts, shallow);
};

// コンポーネントでの使用
const FilteredProductPage = () => {
  const { filters, updateFilters } = useProductFilters();
  const products = useFilteredProducts();

  return (
    <div>
      <ProductFilters
        filters={filters}
        onFiltersChange={updateFilters}
      />
      <ProductList products={products} />
    </div>
  );
};

ベストプラクティス 5:エラーハンドリングの統一パターン

アプリケーション全体で一貫したエラーハンドリングを実装することで、ユーザーエクスペリエンスが向上します。

エラー状態の管理

typescriptinterface ErrorState {
  errors: Record<string, string>;
  setError: (key: string, message: string) => void;
  clearError: (key: string) => void;
  clearAllErrors: () => void;
}

const useErrorStore = create<ErrorState>((set) => ({
  errors: {},
  setError: (key, message) =>
    set((state) => ({
      errors: { ...state.errors, [key]: message },
    })),
  clearError: (key) =>
    set((state) => {
      const { [key]: removed, ...rest } = state.errors;
      return { errors: rest };
    }),
  clearAllErrors: () => set({ errors: {} }),
}));

非同期処理でのエラーハンドリング

typescriptinterface AsyncState<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

const createAsyncSlice = <T>(
  name: string,
  fetchFn: () => Promise<T>
) => ({
  [`${name}State`]: {
    data: null,
    isLoading: false,
    error: null,
  } as AsyncState<T>,

  [`load${name}`]: async () => {
    set((state) => ({
      [`${name}State`]: {
        ...state[`${name}State`],
        isLoading: true,
        error: null,
      },
    }));

    try {
      const data = await fetchFn();
      set((state) => ({
        [`${name}State`]: {
          data,
          isLoading: false,
          error: null,
        },
      }));
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : '不明なエラーが発生しました';

      set((state) => ({
        [`${name}State`]: {
          ...state[`${name}State`],
          isLoading: false,
          error: errorMessage,
        },
      }));
    }
  },
});

// 使用例
const useDataStore = create<DataState & DataActions>(
  (set, get) => ({
    ...createAsyncSlice('users', () => fetchUsers()),
    ...createAsyncSlice('products', () => fetchProducts()),

    // グローバルエラーハンドラー
    handleError: (error: Error, context: string) => {
      console.error(`Error in ${context}:`, error);
      useErrorStore
        .getState()
        .setError(context, error.message);
    },
  })
);

ベストプラクティス 6:テスタビリティを考慮したストア設計

テストしやすいストア設計により、品質の高いアプリケーションを構築できます。

テスト用のストアファクトリー

typescript// ストアファクトリー関数
export const createUserStore = (
  initialState?: Partial<UserState>
) =>
  create<UserState & UserActions>((set, get) => ({
    // デフォルト状態
    currentUser: null,
    isLoading: false,
    error: null,

    // 初期状態のオーバーライド
    ...initialState,

    // アクション
    setUser: (user) => set({ currentUser: user }),
    loadUser: async (userId) => {
      set({ isLoading: true, error: null });
      try {
        const user = await userAPI.fetchUser(userId);
        set({ currentUser: user, isLoading: false });
      } catch (error) {
        set({
          error: error.message,
          isLoading: false,
        });
      }
    },
  }));

// デフォルトストア
export const useUserStore = createUserStore();

ユニットテストの実装

typescript// __tests__/userStore.test.ts
import { act, renderHook } from '@testing-library/react';
import { createUserStore } from '../stores/userStore';

describe('userStore', () => {
  test('should set user correctly', () => {
    const store = createUserStore();
    const { result } = renderHook(() => store());

    const mockUser = {
      id: '1',
      name: 'テストユーザー',
      email: 'test@example.com',
    };

    act(() => {
      result.current.setUser(mockUser);
    });

    expect(result.current.currentUser).toEqual(mockUser);
  });

  test('should handle loading state during async operations', async () => {
    const store = createUserStore();
    const { result } = renderHook(() => store());

    // モックAPI
    const mockFetch = jest.fn().mockResolvedValue({
      id: '1',
      name: 'テストユーザー',
    });

    // API呼び出しをモック
    jest
      .spyOn(userAPI, 'fetchUser')
      .mockImplementation(mockFetch);

    act(() => {
      result.current.loadUser('1');
    });

    // ローディング状態の確認
    expect(result.current.isLoading).toBe(true);
    expect(result.current.error).toBe(null);

    // 非同期処理の完了を待機
    await act(async () => {
      await new Promise((resolve) =>
        setTimeout(resolve, 0)
      );
    });

    expect(result.current.isLoading).toBe(false);
    expect(result.current.currentUser).toEqual({
      id: '1',
      name: 'テストユーザー',
    });
  });
});

統合テストでのストア操作

typescript// __tests__/integration/userFlow.test.tsx
import {
  render,
  screen,
  fireEvent,
  waitFor,
} from '@testing-library/react';
import { createUserStore } from '../stores/userStore';
import UserProfile from '../components/UserProfile';

// テスト用のプロバイダー
const TestProvider = ({ children, initialState = {} }) => {
  const store = createUserStore(initialState);
  return (
    <UserStoreProvider store={store}>
      {children}
    </UserStoreProvider>
  );
};

describe('User Profile Integration', () => {
  test('should display user information after loading', async () => {
    render(
      <TestProvider
        initialState={{
          currentUser: {
            id: '1',
            name: 'テストユーザー',
            email: 'test@example.com',
          },
        }}
      >
        <UserProfile />
      </TestProvider>
    );

    expect(
      screen.getByText('テストユーザー')
    ).toBeInTheDocument();
    expect(
      screen.getByText('test@example.com')
    ).toBeInTheDocument();
  });
});

運用・保守フェーズのベストプラクティス

ベストプラクティス 7:デバッグ環境の構築方法

効率的なデバッグ環境を構築することで、開発生産性が大幅に向上します。

Redux DevTools との連携

typescriptimport { devtools } from 'zustand/middleware';

const useDebugStore = create<State & Actions>()(
  devtools(
    (set, get) => ({
      // ストアの実装
      count: 0,
      increment: () =>
        set(
          (state) => ({ count: state.count + 1 }),
          false,
          'increment'
        ),
      decrement: () =>
        set(
          (state) => ({ count: state.count - 1 }),
          false,
          'decrement'
        ),
      reset: () => set({ count: 0 }, false, 'reset'),
    }),
    {
      name: 'counter-store', // デバッグツールでの表示名
      serialize: true, // シリアライゼーションを有効化
    }
  )
);

カスタムロガーミドルウェア

typescriptconst logger =
  <T>(config: StateCreator<T>, name?: string) =>
  (set: SetState<T>, get: GetState<T>, api: StoreApi<T>) =>
    config(
      (args) => {
        const prevState = get();
        set(args);
        const nextState = get();

        console.group(`🔄 ${name || 'Store'} State Change`);
        console.log('Previous State:', prevState);
        console.log('Next State:', nextState);
        console.groupEnd();
      },
      get,
      api
    );

// 使用例
const useLoggingStore = create<State & Actions>()(
  logger(
    (set, get) => ({
      // ストアの実装
    }),
    'UserStore'
  )
);

開発環境限定デバッグ機能

typescriptconst createDebugStore = <T>(
  config: StateCreator<T>,
  name: string
) => {
  const store = create<T>()(
    process.env.NODE_ENV === 'development'
      ? devtools(logger(config, name), { name })
      : config
  );

  // 開発環境でのみグローバルアクセスを提供
  if (process.env.NODE_ENV === 'development') {
    (window as any)[`${name}Store`] = store;
  }

  return store;
};

// 使用例
export const useUserStore = createDebugStore(
  (set, get) => ({
    // ストアの実装
  }),
  'User'
);

// ブラウザのコンソールで以下のようにアクセス可能
// window.UserStore.getState()
// window.UserStore.setState({ currentUser: null })

ベストプラクティス 8:リファクタリング時の段階的移行戦略

既存のアプリケーションを段階的に Zustand に移行する際の実践的な手法をご紹介します。

レガシーストアとの共存パターン

typescript// 段階的移行のためのアダプター
interface LegacyStoreAdapter<T> {
  getLegacyState: () => T;
  subscribeLegacyChanges: (
    callback: (state: T) => void
  ) => () => void;
}

const createMigrationStore = <T>(
  legacyAdapter: LegacyStoreAdapter<T>,
  newStoreConfig: StateCreator<T>
) => {
  return create<T>((set, get) => {
    // レガシーストアの変更を監視
    const unsubscribe =
      legacyAdapter.subscribeLegacyChanges(
        (legacyState) => {
          set(legacyState);
        }
      );

    // 初期状態をレガシーストアから取得
    const initialState = legacyAdapter.getLegacyState();

    return {
      ...initialState,
      ...newStoreConfig(set, get, {
        destroy: unsubscribe,
      } as any),
    };
  });
};

// Redux→Zustand移行例
const useTransitionStore = createMigrationStore(
  {
    getLegacyState: () => reduxStore.getState().userSlice,
    subscribeLegacyChanges: (callback) => {
      return reduxStore.subscribe(() => {
        callback(reduxStore.getState().userSlice);
      });
    },
  },
  (set, get) => ({
    // 新しいZustandロジック
    newAction: () => {
      // 新機能の実装
    },
  })
);

フィーチャーフラグによる段階的切り替え

typescript// フィーチャーフラグストア
const useFeatureFlagStore = create<{
  flags: Record<string, boolean>;
  isEnabled: (flag: string) => boolean;
}>((set, get) => ({
  flags: {
    useZustandUserStore: false,
    useZustandProductStore: false,
    // 段階的に有効化
  },
  isEnabled: (flag) => get().flags[flag] || false,
}));

// 条件付きストア使用
const useConditionalUserStore = () => {
  const isZustandEnabled = useFeatureFlagStore((state) =>
    state.isEnabled('useZustandUserStore')
  );

  if (isZustandEnabled) {
    return useUserStore(); // 新しいZustandストア
  } else {
    return useLegacyUserStore(); // 既存ストア
  }
};

ベストプラクティス 9:チーム開発での規約とレビューポイント

チーム開発において品質を保つための規約とレビューポイントを明確にします。

コーディング規約の例

typescript// 🔥 Zustand コーディング規約

// 1. インターフェース定義は分離する
interface UserState {
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;
}

interface UserActions {
  setUser: (user: User) => void;
  loadUser: (userId: string) => Promise<void>;
  clearUser: () => void;
}

// 2. 型結合は明示的に行う
type UserStore = UserState & UserActions;

// 3. ストア作成は名前付きエクスポート
export const useUserStore = create<UserStore>(
  (set, get) => ({
    // 4. 状態の初期値は明示的に定義
    currentUser: null,
    isLoading: false,
    error: null,

    // 5. アクションは状態変更の意図を明確にする
    setUser: (user) =>
      set({
        currentUser: user,
        error: null, // 成功時はエラーをクリア
      }),

    // 6. 非同期処理は必ずローディング状態を管理
    loadUser: async (userId) => {
      set({ isLoading: true, error: null });
      try {
        const user = await userAPI.fetchUser(userId);
        set({ currentUser: user, isLoading: false });
      } catch (error) {
        set({
          error: error.message,
          isLoading: false,
          currentUser: null,
        });
      }
    },

    clearUser: () =>
      set({
        currentUser: null,
        error: null,
      }),
  })
);

レビューチェックリスト

markdown# Zustand PR レビューチェックリスト

## 設計

- [ ] ストア分割は適切か?(単一責任原則)
- [ ] 状態の正規化は適切か?
- [ ] 不要な状態の重複はないか?

## 実装

- [ ] TypeScript 型定義は適切か?
- [ ] セレクターによるパフォーマンス最適化は適切か?
- [ ] エラーハンドリングは統一されているか?
- [ ] 非同期処理のローディング状態は管理されているか?

## テスト

- [ ] ユニットテストは十分か?
- [ ] 非同期処理のテストは適切か?
- [ ] エラーケースのテストは含まれているか?

## 保守性

- [ ] アクション名は一貫した命名規則に従っているか?
- [ ] コメントや型定義で意図は明確か?
- [ ] デバッグ用の設定は適切か?

ベストプラクティス 10:本番環境でのモニタリング手法

本番環境での状態管理の健全性を監視する手法をご紹介します。

パフォーマンスモニタリング

typescript// パフォーマンス測定ミドルウェア
const performanceMonitor =
  <T>(config: StateCreator<T>) =>
  (
    set: SetState<T>,
    get: GetState<T>,
    api: StoreApi<T>
  ) => {
    return config(
      (partial, replace, action) => {
        const startTime = performance.now();

        set(partial, replace, action);

        const endTime = performance.now();
        const duration = endTime - startTime;

        // しきい値を超えた場合は警告
        if (duration > 10) {
          // 10ms
          console.warn(
            `Slow state update detected: ${action} took ${duration}ms`
          );

          // 分析用にメトリクスを送信
          if (process.env.NODE_ENV === 'production') {
            analytics.track('slow_state_update', {
              action,
              duration,
              timestamp: Date.now(),
            });
          }
        }
      },
      get,
      api
    );
  };

エラー監視と通知

typescript// エラートラッキングミドルウェア
const errorTracking =
  <T>(config: StateCreator<T>) =>
  (
    set: SetState<T>,
    get: GetState<T>,
    api: StoreApi<T>
  ) => {
    return config(
      (partial, replace, action) => {
        try {
          set(partial, replace, action);
        } catch (error) {
          // エラーログの送信
          console.error(
            `State update error in action: ${action}`,
            error
          );

          // エラートラッキングサービスに送信
          if (process.env.NODE_ENV === 'production') {
            errorReporting.captureException(error, {
              tags: {
                store_action: action,
                component: 'zustand_store',
              },
            });
          }

          // エラーを再スロー
          throw error;
        }
      },
      get,
      api
    );
  };

使用状況の分析

typescript// ストア使用状況の追跡
const usageAnalytics =
  <T>(config: StateCreator<T>, storeName: string) =>
  (
    set: SetState<T>,
    get: GetState<T>,
    api: StoreApi<T>
  ) => {
    const actionCounts = new Map<string, number>();

    return config(
      (partial, replace, action) => {
        // アクション実行回数をカウント
        const currentCount = actionCounts.get(action) || 0;
        actionCounts.set(action, currentCount + 1);

        set(partial, replace, action);

        // 定期的に使用状況を送信
        if (currentCount % 100 === 0) {
          analytics.track('store_usage', {
            store: storeName,
            action,
            count: currentCount + 1,
          });
        }
      },
      get,
      api
    );
  };

// 本番用ストア作成関数
const createProductionStore = <T>(
  config: StateCreator<T>,
  name: string
) => {
  return create<T>()(
    performanceMonitor(
      errorTracking(usageAnalytics(config, name))
    )
  );
};

まとめ

Zustand のベストプラクティス 10 選を実装フェーズ別にご紹介しました。

設計フェーズでは、適切なストア分割戦略、状態の正規化、統一された命名規則が重要です。これらの基盤がしっかりしていると、後の開発がスムーズに進みます。

実装フェーズでは、セレクターによるパフォーマンス最適化、統一されたエラーハンドリング、テスタビリティを意識した設計が品質の高いアプリケーションを生み出します。

運用・保守フェーズでは、デバッグ環境の整備、段階的移行戦略、チーム規約の整備、そして本番環境でのモニタリングが長期的な成功の鍵となります。

これらのプラクティスを段階的に導入していくことで、スケーラブルで保守性の高い Zustand アプリケーションを構築できるでしょう。チーム全体でこれらの知見を共有し、継続的に改善していくことが重要ですね。

関連リンク