T-CREATOR

Zustand Devtools の使い方とデバッグを楽にする活用術

Zustand Devtools の使い方とデバッグを楽にする活用術

Zustand を使った開発で、「なぜ状態が更新されないのか?」「どこでバグが発生しているのか?」といった疑問を抱いたことはありませんか?フロントエンド開発において、状態管理のデバッグは避けて通れない重要なスキルです。今回は、Zustand Devtools を活用して開発効率を劇的に向上させる方法をご紹介します。実際の開発現場で使える実践的なテクニックから、チーム開発での活用方法まで、幅広くカバーしていきますね。

Devtools 環境構築

Redux DevTools Extension のインストール

Zustand Devtools は Redux DevTools Extension を基盤としているため、まずはブラウザ拡張機能のインストールが必要です。

#ブラウザインストール方法
1ChromeChrome Web Store で「Redux DevTools」を検索してインストール
2FirefoxFirefox Add-ons で「Redux DevTools」を検索してインストール
3EdgeMicrosoft Store で「Redux DevTools」を検索してインストール
4SafariApp Store で「Redux DevTools」を検索してインストール

インストール後、ブラウザの開発者ツールを開くと、新しく「Redux」タブが表示されます。これが Zustand Devtools の操作パネルになります。

Zustand での Devtools 設定

次に、Zustand ストアで Devtools を有効化する設定を行います。

typescriptimport { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface UserState {
  users: User[];
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
  setCurrentUser: (user: User) => void;
  clearError: () => void;
}

interface User {
  id: number;
  name: string;
  email: string;
}

const useUserStore = create<UserState>()(
  devtools(
    (set, get) => ({
      users: [],
      currentUser: null,
      isLoading: false,
      error: null,

      fetchUsers: async () => {
        set(
          { isLoading: true, error: null },
          false,
          'fetchUsers/start'
        );

        try {
          const response = await fetch('/api/users');
          const users = await response.json();

          set(
            { users, isLoading: false },
            false,
            'fetchUsers/success'
          );
        } catch (error) {
          set(
            {
              error: error.message,
              isLoading: false,
            },
            false,
            'fetchUsers/error'
          );
        }
      },

      setCurrentUser: (user) => {
        set({ currentUser: user }, false, 'setCurrentUser');
      },

      clearError: () => {
        set({ error: null }, false, 'clearError');
      },
    }),
    {
      name: 'user-store', // Devtools上での表示名
      enabled: process.env.NODE_ENV === 'development', // 開発環境でのみ有効
    }
  )
);

ポイントはsetの第 3 引数でアクション名を指定することです。これにより、Devtools 上でどのアクションが実行されたかが明確になります。

開発環境とプロダクション環境の使い分け

本番環境でのパフォーマンス低下を避けるため、環境に応じた設定を行いましょう。

typescript// 環境変数を活用した条件分岐
const createUserStore = () => {
  const storeConfig = (set, get) => ({
    // ストアの実装
  });

  if (process.env.NODE_ENV === 'development') {
    return create(
      devtools(storeConfig, {
        name: 'user-store',
        enabled: true,
        serialize: true, // 複雑なオブジェクトもシリアライズ
        trace: true, // スタックトレースを含める
      })
    );
  }

  return create(storeConfig);
};

export const useUserStore = createUserStore();

より細かい制御が必要な場合は、以下のような設定も可能です。

typescript// カスタム設定での詳細制御
const devtoolsConfig = {
  name: 'user-store',
  enabled: process.env.NODE_ENV === 'development',
  anonymousActionType: 'unknown', // 未指定アクションの表示名
  serialize: {
    options: {
      undefined: true,
      function: true,
      symbol: true,
    },
  },
};

const useUserStore = create<UserState>()(
  devtools(storeImplementation, devtoolsConfig)
);

基本的なデバッグ機能

State の可視化とリアルタイム監視

Devtools を開くと、現在のストア状態がリアルタイムで表示されます。状態の変化を監視する際の効果的な使い方をご紹介します。

typescript// デバッグしやすい状態構造の設計
interface AppState {
  // セクションごとに状態を分離
  ui: {
    isModalOpen: boolean;
    selectedTab: string;
    notifications: Notification[];
  };
  data: {
    users: User[];
    posts: Post[];
    comments: Comment[];
  };
  async: {
    loading: {
      users: boolean;
      posts: boolean;
      comments: boolean;
    };
    errors: {
      users: string | null;
      posts: string | null;
      comments: string | null;
    };
  };
}

このような構造にすることで、Devtools 上での状態確認が格段に楽になります。

Action の履歴追跡

アクション履歴を効果的に活用するため、意味のあるアクション名を設定します。

typescriptconst useShoppingCartStore = create<ShoppingCartState>()(
  devtools(
    (set, get) => ({
      items: [],
      total: 0,

      addItem: (product: Product, quantity: number) => {
        const currentItems = get().items;
        const existingItem = currentItems.find(
          (item) => item.id === product.id
        );

        if (existingItem) {
          set(
            (state) => ({
              items: state.items.map((item) =>
                item.id === product.id
                  ? {
                      ...item,
                      quantity: item.quantity + quantity,
                    }
                  : item
              ),
            }),
            false,
            `addItem/${product.name}/quantity:${quantity}` // 詳細なアクション名
          );
        } else {
          set(
            (state) => ({
              items: [
                ...state.items,
                { ...product, quantity },
              ],
            }),
            false,
            `addItem/${product.name}/new`
          );
        }

        // 合計金額の更新
        set(
          (state) => ({
            total: state.items.reduce(
              (sum, item) =>
                sum + item.price * item.quantity,
              0
            ),
          }),
          false,
          'updateTotal'
        );
      },
    }),
    { name: 'shopping-cart' }
  )
);

Time Travel デバッグの活用

Time Travel デバッグは、過去の状態に戻って問題を特定する強力な機能です。

typescript// Time Travel デバッグに適した状態管理
const useFormStore = create<FormState>()(
  devtools(
    (set, get) => ({
      formData: {
        name: '',
        email: '',
        phone: '',
      },
      validationErrors: {},
      isSubmitting: false,

      updateField: (
        field: keyof FormData,
        value: string
      ) => {
        set(
          (state) => ({
            formData: {
              ...state.formData,
              [field]: value,
            },
            // バリデーションエラーをクリア
            validationErrors: {
              ...state.validationErrors,
              [field]: null,
            },
          }),
          false,
          `updateField/${field}` // フィールド単位でのアクション追跡
        );
      },

      validateForm: () => {
        const { formData } = get();
        const errors: ValidationErrors = {};

        if (!formData.name.trim()) {
          errors.name = '名前は必須です';
        }

        if (!formData.email.includes('@')) {
          errors.email =
            '有効なメールアドレスを入力してください';
        }

        set(
          { validationErrors: errors },
          false,
          'validateForm'
        );

        return Object.keys(errors).length === 0;
      },
    }),
    { name: 'form-store' }
  )
);

高度なデバッグテクニック

State の差分比較とパフォーマンス監視

複雑な状態変更の際は、差分比較機能を活用します。

typescript// パフォーマンス監視用のカスタムミドルウェア
const performanceLogger = (config) => (set, get, api) =>
  config(
    (...args) => {
      const startTime = performance.now();
      const result = set(...args);
      const endTime = performance.now();

      if (endTime - startTime > 5) {
        // 5ms以上かかった場合に警告
        console.warn(
          `Slow state update: ${endTime - startTime}ms`,
          args
        );
      }

      return result;
    },
    get,
    api
  );

const useOptimizedStore = create(
  devtools(
    performanceLogger((set, get) => ({
      // ストアの実装
      largeDataSet: [],

      processLargeData: (data: LargeData[]) => {
        // 大量データの処理
        const processedData = data.map((item) => ({
          ...item,
          processed: true,
          timestamp: Date.now(),
        }));

        set(
          { largeDataSet: processedData },
          false,
          `processLargeData/count:${data.length}`
        );
      },
    })),
    { name: 'optimized-store' }
  )
);

カスタムアクション名の設定

動的なアクション名でより詳細な追跡を行います。

typescriptconst useNotificationStore = create<NotificationState>()(
  devtools(
    (set, get) => ({
      notifications: [],

      addNotification: (
        type: NotificationType,
        message: string,
        duration?: number
      ) => {
        const id = Date.now().toString();
        const notification: Notification = {
          id,
          type,
          message,
          timestamp: new Date(),
          duration: duration || 5000,
        };

        set(
          (state) => ({
            notifications: [
              ...state.notifications,
              notification,
            ],
          }),
          false,
          `addNotification/${type}/${id}` // タイプとIDを含む詳細な名前
        );

        // 自動削除の設定
        if (notification.duration > 0) {
          setTimeout(() => {
            set(
              (state) => ({
                notifications: state.notifications.filter(
                  (n) => n.id !== id
                ),
              }),
              false,
              `removeNotification/auto/${id}`
            );
          }, notification.duration);
        }
      },

      removeNotification: (id: string) => {
        set(
          (state) => ({
            notifications: state.notifications.filter(
              (n) => n.id !== id
            ),
          }),
          false,
          `removeNotification/manual/${id}`
        );
      },
    }),
    { name: 'notification-store' }
  )
);

複数ストアの同時監視

複数のストアを効率的に監視する方法をご紹介します。

typescript// ストア間の連携を監視しやすくする命名規則
const useAuthStore = create<AuthState>()(
  devtools(
    (set, get) => ({
      user: null,
      isAuthenticated: false,

      login: async (credentials: Credentials) => {
        set({ isLoading: true }, false, 'auth/login/start');

        try {
          const user = await authAPI.login(credentials);
          set(
            {
              user,
              isAuthenticated: true,
              isLoading: false,
            },
            false,
            'auth/login/success'
          );

          // 他のストアに影響を与える場合は明示的に記録
          console.log(
            'Auth success - triggering data fetch'
          );
        } catch (error) {
          set(
            { error: error.message, isLoading: false },
            false,
            'auth/login/error'
          );
        }
      },
    }),
    { name: 'auth-store' }
  )
);

const useDataStore = create<DataState>()(
  devtools(
    (set, get) => ({
      userData: null,

      fetchUserData: async () => {
        const authState = useAuthStore.getState();

        if (!authState.isAuthenticated) {
          console.log(
            'Skipping data fetch - user not authenticated'
          );
          return;
        }

        set({ isLoading: true }, false, 'data/fetch/start');

        try {
          const userData = await dataAPI.fetchUserData();
          set(
            { userData, isLoading: false },
            false,
            'data/fetch/success'
          );
        } catch (error) {
          set(
            { error: error.message, isLoading: false },
            false,
            'data/fetch/error'
          );
        }
      },
    }),
    { name: 'data-store' }
  )
);

実際のデバッグシーン

フォーム入力時の状態変化追跡

実際のフォーム操作でのデバッグ例をご紹介します。

typescript// リアルタイムバリデーション付きフォームストア
const useFormStore = create<FormState>()(
  devtools(
    (set, get) => ({
      fields: {
        email: { value: '', error: null, touched: false },
        password: {
          value: '',
          error: null,
          touched: false,
        },
        confirmPassword: {
          value: '',
          error: null,
          touched: false,
        },
      },
      isValid: false,

      updateField: (fieldName: string, value: string) => {
        const currentState = get();

        set(
          (state) => ({
            fields: {
              ...state.fields,
              [fieldName]: {
                ...state.fields[fieldName],
                value,
                touched: true,
              },
            },
          }),
          false,
          `updateField/${fieldName}/value:${value.substring(
            0,
            10
          )}` // 値の一部を表示
        );

        // リアルタイムバリデーション
        setTimeout(() => {
          get().validateField(fieldName);
        }, 300); // デバウンス
      },

      validateField: (fieldName: string) => {
        const { fields } = get();
        const field = fields[fieldName];
        let error: string | null = null;

        switch (fieldName) {
          case 'email':
            if (!field.value.includes('@')) {
              error =
                'メールアドレスの形式が正しくありません';
            }
            break;
          case 'password':
            if (field.value.length < 8) {
              error =
                'パスワードは8文字以上で入力してください';
            }
            break;
          case 'confirmPassword':
            if (field.value !== fields.password.value) {
              error = 'パスワードが一致しません';
            }
            break;
        }

        set(
          (state) => ({
            fields: {
              ...state.fields,
              [fieldName]: {
                ...state.fields[fieldName],
                error,
              },
            },
          }),
          false,
          `validateField/${fieldName}/result:${
            error ? 'error' : 'valid'
          }`
        );

        // フォーム全体の妥当性を更新
        const updatedFields = {
          ...get().fields,
          [fieldName]: { ...field, error },
        };
        const isValid = Object.values(updatedFields).every(
          (f) => !f.error && f.touched
        );

        set(
          { isValid },
          false,
          `updateFormValidity/${isValid}`
        );
      },
    }),
    { name: 'form-store' }
  )
);

API 通信エラーのデバッグ

API 通信でのエラーハンドリングとデバッグ方法です。

typescript// 詳細なエラー情報を記録するストア
const useApiStore = create<ApiState>()(
  devtools(
    (set, get) => ({
      requests: new Map(),
      errors: [],

      makeRequest: async <T>(
        url: string,
        options: RequestOptions = {}
      ): Promise<T | null> => {
        const requestId = `${Date.now()}-${Math.random()}`;

        // リクエスト開始をログ
        set(
          (state) => ({
            requests: new Map(state.requests).set(
              requestId,
              {
                url,
                status: 'pending',
                startTime: Date.now(),
              }
            ),
          }),
          false,
          `api/request/start/${url}/${requestId}`
        );

        try {
          const response = await fetch(url, {
            ...options,
            headers: {
              'Content-Type': 'application/json',
              ...options.headers,
            },
          });

          if (!response.ok) {
            throw new Error(
              `HTTP ${response.status}: ${response.statusText}`
            );
          }

          const data = await response.json();

          // 成功をログ
          set(
            (state) => ({
              requests: new Map(state.requests).set(
                requestId,
                {
                  url,
                  status: 'success',
                  startTime:
                    state.requests.get(requestId)
                      ?.startTime || Date.now(),
                  endTime: Date.now(),
                  data,
                }
              ),
            }),
            false,
            `api/request/success/${url}/${requestId}`
          );

          return data;
        } catch (error) {
          const errorInfo = {
            id: requestId,
            url,
            message: error.message,
            timestamp: new Date(),
            stack: error.stack,
          };

          // エラーをログ
          set(
            (state) => ({
              requests: new Map(state.requests).set(
                requestId,
                {
                  url,
                  status: 'error',
                  startTime:
                    state.requests.get(requestId)
                      ?.startTime || Date.now(),
                  endTime: Date.now(),
                  error: errorInfo,
                }
              ),
              errors: [...state.errors, errorInfo],
            }),
            false,
            `api/request/error/${url}/${error.message}/${requestId}`
          );

          return null;
        }
      },
    }),
    { name: 'api-store' }
  )
);

非同期処理の状態管理

複雑な非同期処理のデバッグ方法をご紹介します。

typescript// 並列処理を含む非同期操作のデバッグ
const useAsyncStore = create<AsyncState>()(
  devtools(
    (set, get) => ({
      tasks: new Map(),
      results: new Map(),

      executeParallelTasks: async (
        taskConfigs: TaskConfig[]
      ) => {
        const batchId = `batch-${Date.now()}`;

        set(
          (state) => ({
            tasks: new Map(state.tasks).set(batchId, {
              status: 'started',
              taskCount: taskConfigs.length,
              completedCount: 0,
              startTime: Date.now(),
            }),
          }),
          false,
          `async/batch/start/${batchId}/tasks:${taskConfigs.length}`
        );

        const taskPromises = taskConfigs.map(
          async (config, index) => {
            const taskId = `${batchId}-task-${index}`;

            try {
              set(
                (state) => {
                  const batch = state.tasks.get(batchId);
                  return {
                    tasks: new Map(state.tasks).set(
                      batchId,
                      {
                        ...batch,
                        [`task-${index}`]: 'running',
                      }
                    ),
                  };
                },
                false,
                `async/task/start/${taskId}`
              );

              const result = await config.execute();

              set(
                (state) => {
                  const batch = state.tasks.get(batchId);
                  return {
                    tasks: new Map(state.tasks).set(
                      batchId,
                      {
                        ...batch,
                        [`task-${index}`]: 'completed',
                        completedCount:
                          batch.completedCount + 1,
                      }
                    ),
                    results: new Map(state.results).set(
                      taskId,
                      result
                    ),
                  };
                },
                false,
                `async/task/success/${taskId}`
              );

              return { taskId, result, error: null };
            } catch (error) {
              set(
                (state) => {
                  const batch = state.tasks.get(batchId);
                  return {
                    tasks: new Map(state.tasks).set(
                      batchId,
                      {
                        ...batch,
                        [`task-${index}`]: 'error',
                        completedCount:
                          batch.completedCount + 1,
                      }
                    ),
                  };
                },
                false,
                `async/task/error/${taskId}/${error.message}`
              );

              return { taskId, result: null, error };
            }
          }
        );

        const results = await Promise.allSettled(
          taskPromises
        );

        set(
          (state) => {
            const batch = state.tasks.get(batchId);
            return {
              tasks: new Map(state.tasks).set(batchId, {
                ...batch,
                status: 'completed',
                endTime: Date.now(),
              }),
            };
          },
          false,
          `async/batch/complete/${batchId}`
        );

        return results;
      },
    }),
    { name: 'async-store' }
  )
);

チーム開発での活用術

デバッグ情報の共有方法

チーム間でのデバッグ情報共有を効率化する方法をご紹介します。

typescript// デバッグ情報エクスポート機能付きストア
const useDebugStore = create<DebugState>()(
  devtools(
    (set, get) => ({
      debugMode: process.env.NODE_ENV === 'development',
      sessionId: crypto.randomUUID(),
      actionHistory: [],

      exportDebugInfo: () => {
        const state = get();
        const debugInfo = {
          sessionId: state.sessionId,
          timestamp: new Date().toISOString(),
          userAgent: navigator.userAgent,
          url: window.location.href,
          actionHistory: state.actionHistory.slice(-50), // 直近50アクション
          currentState: {
            // 機密情報を除外した状態
            ...state,
            actionHistory: undefined,
            sensitiveData: '[REDACTED]',
          },
        };

        // クリップボードにコピー
        navigator.clipboard.writeText(
          JSON.stringify(debugInfo, null, 2)
        );

        set(
          { lastExport: Date.now() },
          false,
          'debug/export/clipboard'
        );

        return debugInfo;
      },

      logAction: (action: string, payload?: any) => {
        if (!get().debugMode) return;

        const logEntry = {
          timestamp: Date.now(),
          action,
          payload: payload
            ? JSON.parse(JSON.stringify(payload))
            : null,
          url: window.location.pathname,
        };

        set(
          (state) => ({
            actionHistory: [
              ...state.actionHistory.slice(-99),
              logEntry,
            ], // 最大100件保持
          }),
          false,
          'debug/log/action'
        );
      },
    }),
    { name: 'debug-store' }
  )
);

// 使用例:他のストアでのアクションログ
const useUserStore = create<UserState>()(
  devtools(
    (set, get) => ({
      // ... 他の状態

      updateProfile: async (profileData: ProfileData) => {
        // デバッグログの記録
        useDebugStore
          .getState()
          .logAction('user/updateProfile/start', {
            userId: get().currentUser?.id,
            fields: Object.keys(profileData),
          });

        try {
          const result = await userAPI.updateProfile(
            profileData
          );

          set(
            { currentUser: result },
            false,
            'user/updateProfile/success'
          );

          useDebugStore
            .getState()
            .logAction('user/updateProfile/success', {
              userId: result.id,
            });
        } catch (error) {
          useDebugStore
            .getState()
            .logAction('user/updateProfile/error', {
              error: error.message,
            });

          throw error;
        }
      },
    }),
    { name: 'user-store' }
  )
);

バグレポートでの Devtools 活用

効果的なバグレポート作成のための Devtools 活用法です。

typescript// バグレポート生成機能
const useBugReportStore = create<BugReportState>()(
  devtools(
    (set, get) => ({
      reports: [],

      generateBugReport: (
        description: string,
        steps: string[]
      ) => {
        const report: BugReport = {
          id: crypto.randomUUID(),
          timestamp: new Date().toISOString(),
          description,
          steps,
          environment: {
            userAgent: navigator.userAgent,
            url: window.location.href,
            viewport: {
              width: window.innerWidth,
              height: window.innerHeight,
            },
            timestamp: Date.now(),
          },
          // 各ストアの現在状態をスナップショット
          storeSnapshots: {
            user: useUserStore.getState(),
            ui: useUIStore.getState(),
            data: useDataStore.getState(),
          },
          // Devtoolsから最近のアクション履歴を取得
          recentActions: getRecentActions(), // 後述の実装
          console: getRecentConsoleMessages(), // コンソールログも含める
        };

        set(
          (state) => ({
            reports: [...state.reports, report],
          }),
          false,
          `bugReport/create/${report.id}`
        );

        return report;
      },

      exportReport: (reportId: string) => {
        const report = get().reports.find(
          (r) => r.id === reportId
        );
        if (!report) return null;

        const exportData = {
          ...report,
          // 機密情報の除外
          storeSnapshots: Object.fromEntries(
            Object.entries(report.storeSnapshots).map(
              ([key, value]) => [
                key,
                sanitizeForExport(value),
              ]
            )
          ),
        };

        // JSONファイルとしてダウンロード
        const blob = new Blob(
          [JSON.stringify(exportData, null, 2)],
          {
            type: 'application/json',
          }
        );
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `bug-report-${reportId.slice(
          0,
          8
        )}.json`;
        a.click();
        URL.revokeObjectURL(url);

        set(
          { lastExport: Date.now() },
          false,
          `bugReport/export/${reportId}`
        );

        return exportData;
      },
    }),
    { name: 'bug-report-store' }
  )
);

// ヘルパー関数
function getRecentActions(): ActionHistory[] {
  // Redux DevTools APIを使用してアクション履歴を取得
  if (
    typeof window !== 'undefined' &&
    window.__REDUX_DEVTOOLS_EXTENSION__
  ) {
    const devtools = window.__REDUX_DEVTOOLS_EXTENSION__;
    // 実際の実装では、DevTools APIを使用してアクション履歴を取得
    return [];
  }
  return [];
}

function getRecentConsoleMessages(): ConsoleMessage[] {
  // コンソールメッセージの履歴を取得(カスタム実装が必要)
  return [];
}

function sanitizeForExport(data: any): any {
  // 機密情報を除外する処理
  const sanitized = JSON.parse(JSON.stringify(data));

  // 一般的な機密フィールドを除外
  const sensitiveFields = [
    'password',
    'token',
    'secret',
    'key',
    'auth',
  ];

  function recursiveSanitize(obj: any): any {
    if (typeof obj !== 'object' || obj === null) return obj;

    const result = Array.isArray(obj) ? [] : {};

    for (const [key, value] of Object.entries(obj)) {
      if (
        sensitiveFields.some((field) =>
          key.toLowerCase().includes(field)
        )
      ) {
        result[key] = '[REDACTED]';
      } else if (typeof value === 'object') {
        result[key] = recursiveSanitize(value);
      } else {
        result[key] = value;
      }
    }

    return result;
  }

  return recursiveSanitize(sanitized);
}

まとめ

Zustand Devtools は、単なるデバッグツール以上の価値を持つ強力な開発支援ツールです。今回ご紹介した活用術を実践することで、以下のような効果が期待できます。

開発効率の向上: リアルタイムでの状態監視により、問題の早期発見と迅速な修正が可能になります。特に、アクション履歴と Time Travel デバッグを組み合わせることで、バグの根本原因を素早く特定できるでしょう。

チーム開発の円滑化: 統一されたデバッグ情報の記録と共有により、チームメンバー間でのコミュニケーションが改善されます。バグレポート機能を活用することで、再現可能な詳細な情報を提供できますね。

コードの品質向上: パフォーマンス監視機能を使うことで、状態更新の最適化ポイントが明確になります。また、構造化されたアクション名により、コードの可読性も向上します。

Devtools は開発フェーズだけでなく、テスト段階や本番環境での問題調査にも威力を発揮します。ぜひ、今日から実際のプロジェクトで活用してみてください。最初は設定に時間がかかるかもしれませんが、長期的に見れば開発時間の大幅な短縮につながることでしょう。

次のステップとして、プロジェクト固有のカスタムミドルウェアの開発や、CI/CD パイプラインでの Devtools 活用なども検討してみてくださいね。

関連リンク