T-CREATOR

Zustand で状態変更のトリガーイベントを効率的に管理する方法

Zustand で状態変更のトリガーイベントを効率的に管理する方法

React アプリケーションの状態管理において、「いつ状態が変わったのか」「どの部分が更新されたのか」を正確に把握することは、効率的な開発とデバッグのために不可欠です。特に大規模なアプリケーションでは、状態変更のタイミングを適切に監視し、必要な処理を実行することが求められます。

本記事では、軽量で使いやすい状態管理ライブラリである Zustand を使用して、状態変更のトリガーイベントを効率的に管理する方法について、基本的な概念から実践的なテクニックまで段階的に解説していきます。

背景

React の状態管理における課題

現代の React アプリケーション開発では、複雑な状態管理が避けて通れない課題となっています。従来の useState や useReducer だけでは、以下のような問題が発生することがあります。

  • プロップドリリング:深い階層のコンポーネント間でのデータ受け渡しが煩雑
  • 状態の分散:関連する状態が複数のコンポーネントに散らばる
  • デバッグの困難さ:状態がいつ、どこで変更されたかの追跡が難しい

これらの課題を解決するために、Redux や Context API などの状態管理ソリューションが広く使われてきました。しかし、これらのライブラリには学習コストの高さや、ボイラープレートコードの多さという問題もありました。

Zustand の基本的な状態管理とイベント処理の現状

Zustand は、これらの課題を解決するために開発されたミニマルな状態管理ライブラリです。わずか 2KB という軽量性と、直感的な API が特徴です。

基本的な Zustand ストアの作成は以下のように行います:

javascriptimport { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
}));

この例では、countという状態と、それを操作するincrementdecrementアクションを定義しています。しかし、この基本的な実装では、状態がいつ変更されたかを監視する仕組みが含まれていません。

課題

状態変更の監視が困難

Zustand の基本的な使用方法では、状態変更を監視する機能が限られています。例えば、以下のような状況で困難が生じます:

javascript// ユーザーがボタンをクリックしたときの処理
const handleClick = () => {
  increment();
  // この時点で状態が変更されているかどうかを知る方法が限られている
  console.log('カウントが更新されました'); // いつ実行されるかが不明確
};

デバッグ時の状態追跡の問題

複雑なアプリケーションでは、状態変更の原因を特定することが困難になります。特に以下のようなエラーが発生した場合:

javascriptError: Cannot read property 'map' of undefined
  at UserList (/src/components/UserList.js:15:23)
  at render (/node_modules/react-dom/cjs/react-dom.js:1173:12)

このエラーが発生したとき、users配列が undefined になった原因や、いつその状態変更が発生したかを特定するのは非常に困難です。

パフォーマンスの最適化不足

状態変更の監視を適切に行わないと、以下のようなパフォーマンスの問題が発生する可能性があります:

  • 不要な再レンダリング:関係のない状態変更でもコンポーネントが再レンダリングされる
  • メモリリーク:適切にクリーンアップされないイベントリスナー
  • CPU 使用率の増加:効率的でない状態監視による処理負荷

解決策

subscribe メソッドによる基本的な監視

Zustand では、subscribeメソッドを使用して状態変更を監視できます。この機能を活用することで、状態変更のタイミングを正確に把握できます。

javascriptimport { create } from 'zustand';

const useStore = create((set, get) => ({
  count: 0,
  users: [],
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  setUsers: (users) => set({ users }),
}));

// 状態変更を監視
const unsubscribe = useStore.subscribe(
  (state, prevState) => {
    console.log('状態が変更されました:', {
      before: prevState,
      after: state,
      timestamp: new Date().toISOString(),
    });
  }
);

上記のコードでは、状態が変更されるたびにコールバック関数が実行され、変更前後の状態と変更時刻がログ出力されます。

カスタムリスナーの実装

より高度な監視機能を実装するために、カスタムリスナーを作成できます:

javascript// カスタムリスナーの実装
const createCustomListener = (store) => {
  const listeners = new Map();

  const addListener = (key, callback) => {
    if (!listeners.has(key)) {
      listeners.set(key, []);
    }
    listeners.get(key).push(callback);
  };

  const removeListener = (key, callback) => {
    const keyListeners = listeners.get(key);
    if (keyListeners) {
      const index = keyListeners.indexOf(callback);
      if (index > -1) {
        keyListeners.splice(index, 1);
      }
    }
  };

  const notifyListeners = (key, newValue, oldValue) => {
    const keyListeners = listeners.get(key) || [];
    keyListeners.forEach((callback) => {
      try {
        callback(newValue, oldValue);
      } catch (error) {
        console.error(
          `Listener error for key "${key}":`,
          error
        );
      }
    });
  };

  return { addListener, removeListener, notifyListeners };
};

TypeScript での型安全なイベント管理

TypeScript を使用することで、より安全で保守性の高い状態管理システムを構築できます:

typescriptinterface StoreState {
  count: number;
  users: User[];
  loading: boolean;
}

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

interface StoreActions {
  increment: () => void;
  setUsers: (users: User[]) => void;
  setLoading: (loading: boolean) => void;
}

type Store = StoreState & StoreActions;

// 型安全なストアの作成
const useStore = create<Store>((set, get) => ({
  count: 0,
  users: [],
  loading: false,

  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  setUsers: (users: User[]) => set({ users }),
  setLoading: (loading: boolean) => set({ loading }),
}));

// 型安全なサブスクリプション
type StateChangeListener<T> = (
  newState: T,
  prevState: T
) => void;

const subscribeToStateChanges = <T>(
  selector: (state: Store) => T,
  listener: StateChangeListener<T>
) => {
  let prevValue = selector(useStore.getState());

  return useStore.subscribe((state) => {
    const newValue = selector(state);
    if (newValue !== prevValue) {
      listener(newValue, prevValue);
      prevValue = newValue;
    }
  });
};

具体例

基本的な状態変更の監視

最もシンプルな状態変更監視の実装例です:

javascriptimport { create } from 'zustand';
import { useEffect } from 'react';

// カウンターストアの作成
const useCounterStore = create((set, get) => ({
  count: 0,
  history: [],

  increment: () =>
    set((state) => ({
      count: state.count + 1,
      history: [
        ...state.history,
        { action: 'increment', timestamp: Date.now() },
      ],
    })),

  decrement: () =>
    set((state) => ({
      count: state.count - 1,
      history: [
        ...state.history,
        { action: 'decrement', timestamp: Date.now() },
      ],
    })),

  reset: () =>
    set(() => ({
      count: 0,
      history: [{ action: 'reset', timestamp: Date.now() }],
    })),
}));

// カウンター変更を監視するコンポーネント
const CounterMonitor = () => {
  const count = useCounterStore((state) => state.count);

  useEffect(() => {
    const unsubscribe = useCounterStore.subscribe(
      (state, prevState) => {
        if (state.count !== prevState.count) {
          console.log(
            `カウンターが ${prevState.count} から ${state.count} に変更されました`
          );

          // 特定の値に達した場合の処理
          if (state.count === 10) {
            alert('カウンターが10に達しました!');
          }

          // 負の値になった場合の警告
          if (state.count < 0) {
            console.warn('カウンターが負の値になりました');
          }
        }
      }
    );

    return unsubscribe;
  }, []);

  return (
    <div>
      <p>現在のカウント: {count}</p>
      <button
        onClick={useCounterStore.getState().increment}
      >
        +1
      </button>
      <button
        onClick={useCounterStore.getState().decrement}
      >
        -1
      </button>
      <button onClick={useCounterStore.getState().reset}>
        リセット
      </button>
    </div>
  );
};

複数状態の効率的な監視

複数の状態を効率的に監視する実装例です:

javascriptimport { create } from 'zustand';

// 複数の状態を管理するストア
const useAppStore = create((set, get) => ({
  user: null,
  cart: [],
  notifications: [],

  setUser: (user) => set({ user }),
  addToCart: (product) =>
    set((state) => ({
      cart: [...state.cart, { ...product, id: Date.now() }],
    })),
  removeFromCart: (productId) =>
    set((state) => ({
      cart: state.cart.filter(
        (item) => item.id !== productId
      ),
    })),
  addNotification: (message) =>
    set((state) => ({
      notifications: [
        ...state.notifications,
        {
          id: Date.now(),
          message,
          timestamp: new Date().toISOString(),
        },
      ],
    })),
}));

// 選択的な状態監視を行うカスタムフック
const useStateMonitor = () => {
  useEffect(() => {
    // ユーザー状態の監視
    const unsubscribeUser = useAppStore.subscribe(
      (state) => state.user,
      (user, prevUser) => {
        if (user && !prevUser) {
          console.log(
            'ユーザーがログインしました:',
            user.name
          );
          // 分析ツールへのイベント送信
          analytics.track('user_login', {
            userId: user.id,
          });
        } else if (!user && prevUser) {
          console.log('ユーザーがログアウトしました');
          analytics.track('user_logout', {
            userId: prevUser.id,
          });
        }
      }
    );

    // カート状態の監視
    const unsubscribeCart = useAppStore.subscribe(
      (state) => state.cart,
      (cart, prevCart) => {
        const cartValue = cart.reduce(
          (sum, item) => sum + item.price,
          0
        );
        const prevCartValue = prevCart.reduce(
          (sum, item) => sum + item.price,
          0
        );

        if (cart.length > prevCart.length) {
          console.log('商品がカートに追加されました');
          useAppStore
            .getState()
            .addNotification('商品をカートに追加しました');
        } else if (cart.length < prevCart.length) {
          console.log('商品がカートから削除されました');
          useAppStore
            .getState()
            .addNotification(
              '商品をカートから削除しました'
            );
        }

        // 高額商品の警告
        if (cartValue > 10000 && prevCartValue <= 10000) {
          useAppStore
            .getState()
            .addNotification(
              '⚠️ カート内の商品が10,000円を超えました'
            );
        }
      }
    );

    return () => {
      unsubscribeUser();
      unsubscribeCart();
    };
  }, []);
};

デバッグ用のロガー実装

開発時のデバッグを効率化するロガーの実装例です:

javascript// デバッグ用のロガークラス
class ZustandLogger {
  constructor(storeName = 'Store') {
    this.storeName = storeName;
    this.logs = [];
    this.maxLogs = 100;
  }

  log(action, prevState, nextState, error = null) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      action: action || 'UNKNOWN_ACTION',
      prevState: JSON.parse(JSON.stringify(prevState)),
      nextState: JSON.parse(JSON.stringify(nextState)),
      error,
      diff: this.calculateDiff(prevState, nextState),
    };

    this.logs.unshift(logEntry);

    // ログ数の制限
    if (this.logs.length > this.maxLogs) {
      this.logs = this.logs.slice(0, this.maxLogs);
    }

    // 開発環境でのコンソール出力
    if (process.env.NODE_ENV === 'development') {
      console.group(`🔄 ${this.storeName} State Change`);
      console.log('⏰ Timestamp:', logEntry.timestamp);
      console.log('🎯 Action:', logEntry.action);
      console.log('📋 Previous State:', logEntry.prevState);
      console.log('📝 Next State:', logEntry.nextState);
      console.log('🔍 Diff:', logEntry.diff);
      if (error) {
        console.error('❌ Error:', error);
      }
      console.groupEnd();
    }

    return logEntry;
  }

  calculateDiff(prev, next) {
    const diff = {};
    const allKeys = new Set([
      ...Object.keys(prev),
      ...Object.keys(next),
    ]);

    allKeys.forEach((key) => {
      const prevValue = prev[key];
      const nextValue = next[key];

      if (
        JSON.stringify(prevValue) !==
        JSON.stringify(nextValue)
      ) {
        diff[key] = {
          from: prevValue,
          to: nextValue,
        };
      }
    });

    return diff;
  }

  getRecentLogs(count = 10) {
    return this.logs.slice(0, count);
  }

  clearLogs() {
    this.logs = [];
    console.log(`${this.storeName} logs cleared`);
  }

  exportLogs() {
    const data = JSON.stringify(this.logs, null, 2);
    const blob = new Blob([data], {
      type: 'application/json',
    });
    const url = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = url;
    a.download = `${this.storeName.toLowerCase()}-logs-${Date.now()}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }
}

// ロガー付きのストア作成
const logger = new ZustandLogger('UserStore');

const useUserStore = create((set, get) => {
  let currentAction = null;

  const loggedSet = (updater, action = 'SET_STATE') => {
    const prevState = get();
    currentAction = action;

    try {
      set((state) => {
        const newState =
          typeof updater === 'function'
            ? updater(state)
            : updater;
        logger.log(currentAction, state, {
          ...state,
          ...newState,
        });
        return newState;
      });
    } catch (error) {
      logger.log(
        currentAction,
        prevState,
        prevState,
        error
      );
      throw error;
    }
  };

  return {
    users: [],
    loading: false,
    error: null,

    fetchUsers: async () => {
      loggedSet(
        { loading: true, error: null },
        'FETCH_USERS_START'
      );

      try {
        const response = await fetch('/api/users');
        if (!response.ok) {
          throw new Error(
            `HTTP error! status: ${response.status}`
          );
        }
        const users = await response.json();
        loggedSet(
          { users, loading: false },
          'FETCH_USERS_SUCCESS'
        );
      } catch (error) {
        loggedSet(
          {
            loading: false,
            error: error.message,
          },
          'FETCH_USERS_ERROR'
        );
      }
    },

    addUser: (user) => {
      loggedSet(
        (state) => ({
          users: [
            ...state.users,
            { ...user, id: Date.now() },
          ],
        }),
        'ADD_USER'
      );
    },

    removeUser: (userId) => {
      loggedSet(
        (state) => ({
          users: state.users.filter(
            (user) => user.id !== userId
          ),
        }),
        'REMOVE_USER'
      );
    },

    // デバッグ用のメソッド
    _debug: {
      getLogs: () => logger.getRecentLogs(),
      clearLogs: () => logger.clearLogs(),
      exportLogs: () => logger.exportLogs(),
    },
  };
});

パフォーマンス最適化のテクニック

大規模アプリケーションでのパフォーマンス最適化テクニックです:

javascriptimport { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

// パフォーマンス最適化されたストア
const useOptimizedStore = create(
  subscribeWithSelector((set, get) => ({
    // 大量のデータを想定
    items: [],
    filters: {
      search: '',
      category: 'all',
      sortBy: 'name',
    },
    selectedItems: new Set(),

    // アクション
    setItems: (items) => set({ items }),
    updateFilters: (newFilters) =>
      set((state) => ({
        filters: { ...state.filters, ...newFilters },
      })),
    toggleItemSelection: (itemId) =>
      set((state) => {
        const newSelectedItems = new Set(
          state.selectedItems
        );
        if (newSelectedItems.has(itemId)) {
          newSelectedItems.delete(itemId);
        } else {
          newSelectedItems.add(itemId);
        }
        return { selectedItems: newSelectedItems };
      }),
  }))
);

// デバウンス機能付きの監視
const createDebouncedSubscription = (
  callback,
  delay = 300
) => {
  let timeoutId = null;

  return (...args) => {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    timeoutId = setTimeout(() => {
      callback(...args);
      timeoutId = null;
    }, delay);
  };
};

// 効率的な状態監視の実装
const usePerformanceMonitor = () => {
  useEffect(() => {
    // 検索フィルターの変更を監視(デバウンス付き)
    const unsubscribeSearch = useOptimizedStore.subscribe(
      (state) => state.filters.search,
      createDebouncedSubscription((search, prevSearch) => {
        if (search !== prevSearch) {
          console.log(
            `検索フィルターが変更されました: "${prevSearch}" → "${search}"`
          );
          // 検索APIの呼び出しなど
          performSearch(search);
        }
      }, 500)
    );

    // 選択アイテムの変更を監視(バッチ処理)
    let batchTimeout = null;
    const selectedItemsChanges = [];

    const unsubscribeSelection =
      useOptimizedStore.subscribe(
        (state) => state.selectedItems,
        (selectedItems, prevSelectedItems) => {
          selectedItemsChanges.push({
            added: [...selectedItems].filter(
              (id) => !prevSelectedItems.has(id)
            ),
            removed: [...prevSelectedItems].filter(
              (id) => !selectedItems.has(id)
            ),
            timestamp: Date.now(),
          });

          // バッチ処理でまとめて実行
          if (batchTimeout) {
            clearTimeout(batchTimeout);
          }

          batchTimeout = setTimeout(() => {
            if (selectedItemsChanges.length > 0) {
              console.log(
                '選択アイテムの変更をバッチ処理:',
                selectedItemsChanges
              );
              // 分析やAPIへの送信など
              processSelectionChanges(selectedItemsChanges);
              selectedItemsChanges.length = 0;
            }
            batchTimeout = null;
          }, 100);
        }
      );

    return () => {
      unsubscribeSearch();
      unsubscribeSelection();
      if (batchTimeout) {
        clearTimeout(batchTimeout);
      }
    };
  }, []);
};

// メモ化を活用した効率的なセレクター
const useOptimizedSelectors = () => {
  // メモ化されたフィルタリング済みアイテム
  const filteredItems = useOptimizedStore(
    useCallback((state) => {
      const { items, filters } = state;

      return items
        .filter((item) => {
          if (
            filters.search &&
            !item.name
              .toLowerCase()
              .includes(filters.search.toLowerCase())
          ) {
            return false;
          }
          if (
            filters.category !== 'all' &&
            item.category !== filters.category
          ) {
            return false;
          }
          return true;
        })
        .sort((a, b) => {
          switch (filters.sortBy) {
            case 'name':
              return a.name.localeCompare(b.name);
            case 'price':
              return a.price - b.price;
            case 'date':
              return (
                new Date(b.createdAt) -
                new Date(a.createdAt)
              );
            default:
              return 0;
          }
        });
    }, [])
  );

  // 選択されたアイテムの合計価格
  const selectedItemsTotalPrice = useOptimizedStore(
    useCallback((state) => {
      const { items, selectedItems } = state;
      return items
        .filter((item) => selectedItems.has(item.id))
        .reduce((total, item) => total + item.price, 0);
    }, [])
  );

  return { filteredItems, selectedItemsTotalPrice };
};

// パフォーマンス測定用のユーティリティ
const performanceProfiler = {
  measurements: new Map(),

  start(key) {
    this.measurements.set(key, {
      startTime: performance.now(),
      endTime: null,
      duration: null,
    });
  },

  end(key) {
    const measurement = this.measurements.get(key);
    if (measurement && !measurement.endTime) {
      measurement.endTime = performance.now();
      measurement.duration =
        measurement.endTime - measurement.startTime;

      if (process.env.NODE_ENV === 'development') {
        console.log(
          `⚡ Performance [${key}]: ${measurement.duration.toFixed(
            2
          )}ms`
        );
      }

      return measurement.duration;
    }
    return null;
  },

  getResults() {
    const results = {};
    this.measurements.forEach((measurement, key) => {
      results[key] = measurement;
    });
    return results;
  },

  clear() {
    this.measurements.clear();
  },
};

// パフォーマンス測定付きのアクション実行
const performActionWithProfiling = (actionName, action) => {
  performanceProfiler.start(actionName);
  const result = action();
  performanceProfiler.end(actionName);
  return result;
};

まとめ

Zustand で状態変更のトリガーイベントを効率的に管理するためには、以下のポイントが重要です:

基本的な監視システムの確立

subscribeメソッドを活用することで、状態変更のタイミングを正確に把握し、必要な処理を実行できます。これにより、デバッグ時の状態追跡が大幅に改善されます。

型安全性の確保

TypeScript を使用することで、状態変更の監視処理においても型安全性を保つことができ、開発効率と保守性が向上します。

パフォーマンスの最適化

デバウンス処理やバッチ処理、メモ化などのテクニックを組み合わせることで、大規模アプリケーションでも快適なユーザー体験を提供できます。

開発者体験の向上

適切なロギングシステムやデバッグツールを実装することで、開発チーム全体の生産性向上に寄与します。

これらの手法を組み合わせることで、Zustand を使用したアプリケーションの状態管理をより効率的で信頼性の高いものにできます。特に大規模なアプリケーションや、リアルタイム性が求められるアプリケーションでは、これらのテクニックの恩恵を大きく感じることができるでしょう。

状態変更のトリガーイベント管理は、単なる技術的な実装にとどまらず、ユーザー体験の向上や開発チームの生産性向上に直結する重要な要素です。今回紹介した手法を参考に、皆さまのプロジェクトに最適な状態管理システムを構築していただければと思います。

関連リンク