T-CREATOR

JotaiのonMount で atom のライフサイクルを管理する - 初期化処理やクリーンアップをエレガントに

JotaiのonMount で atom のライフサイクルを管理する - 初期化処理やクリーンアップをエレガントに

React 開発において、状態管理はアプリケーションの成功を左右する重要な要素です。特に、状態の初期化やクリーンアップは、メモリリークやパフォーマンスの問題を避けるために欠かせません。

今回は、Jotai の onMount を使用して、atom のライフサイクルをエレガントに管理する方法をお伝えします。この手法により、初期化処理やクリーンアップを効率的に実装できるようになります。

atom ライフサイクルと onMount の基本概念

atom の基本的なライフサイクル

Jotai の atom は、React のコンポーネントライフサイクルとは異なる独自のライフサイクルを持っています。

atom は以下のような段階を経て管理されます:

  1. 作成:atom の定義
  2. 初期化:初期値の設定
  3. マウント:コンポーネントで使用開始
  4. 更新:値の変更
  5. アンマウント:コンポーネントから削除
  6. クリーンアップ:リソースの解放
typescriptimport { atom } from 'jotai';

// 基本的なatomの作成
const counterAtom = atom(0);

// 計算されたatomの例
const doubleCountAtom = atom((get) => get(counterAtom) * 2);

上記のコードは、Jotai における atom の基本的な定義方法を示しています。counterAtomは基本的なプリミティブ atom、doubleCountAtomは他の atom に依存する計算された atom です。

onMount の役割と位置づけ

onMount は、atom が実際に使用されるタイミングで実行される初期化処理を定義するためのメカニズムです。

typescriptimport { atom } from 'jotai';

const userDataAtom = atom(null);

// onMountを使用した初期化処理
userDataAtom.onMount = (setAtom) => {
  // 初期化処理
  console.log('userDataAtom がマウントされました');

  // APIからデータを取得
  fetchUserData().then((data) => {
    setAtom(data);
  });

  // クリーンアップ関数を返す
  return () => {
    console.log('userDataAtom がアンマウントされました');
  };
};

この onMount の実装により、atom が実際に使用されるタイミングで初期化処理が実行され、不要になったときにクリーンアップが実行されます。

従来の課題と問題点

初期化処理の問題

従来の useEffect を使用した状態管理では、以下のような問題がありました:

typescript// 従来の問題のあるパターン
function UserProfile() {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    fetchUserData().then(setUserData);
  }, []); // 依存関係の管理が複雑

  return <div>{userData?.name}</div>;
}

このような実装では、以下の問題が発生します:

依存関係の管理が困難

  • useEffect の依存配列の管理が複雑
  • 複数のコンポーネントで同じデータを使用する場合、重複した処理が発生

状態の共有が困難

  • コンポーネント間でのデータ共有が複雑
  • プロップスドリリング問題

クリーンアップの煩雑さ

typescript// 複雑なクリーンアップ処理
function WebSocketComponent() {
  const [socket, setSocket] = useState(null);

  useEffect(() => {
    const ws = new WebSocket('ws://localhost:8080');

    ws.onopen = () => console.log('接続しました');
    ws.onmessage = (event) => {
      // メッセージ処理
    };

    setSocket(ws);

    // クリーンアップ
    return () => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    };
  }, []);

  return <div>WebSocket Status: {socket?.readyState}</div>;
}

従来の方法では、クリーンアップ処理が複雑になり、メモリリークのリスクが高くなります。

メモリリークのリスク

適切なクリーンアップが行われない場合、以下のような問題が発生します:

typescript// メモリリークの例
useEffect(() => {
  const interval = setInterval(() => {
    console.log('タイマー実行中');
  }, 1000);

  // クリーンアップを忘れるとメモリリークが発生
  // return () => clearInterval(interval)
}, []);

このような実装では、コンポーネントがアンマウントされても、インターバルが継続して実行され続けます。

onMount を使った解決策

onMount の基本的な使い方

onMount を使用することで、atom のライフサイクルを効率的に管理できます:

typescriptimport { atom } from 'jotai';

const timerAtom = atom(0);

// onMountでライフサイクルを管理
timerAtom.onMount = (setAtom) => {
  console.log('タイマーatomがマウントされました');

  // 初期化処理
  const interval = setInterval(() => {
    setAtom((prev) => prev + 1);
  }, 1000);

  // クリーンアップ関数を返す
  return () => {
    console.log('タイマーatomがアンマウントされました');
    clearInterval(interval);
  };
};

このパターンにより、atom が使用されるタイミングで初期化処理が実行され、不要になったときに自動的にクリーンアップが実行されます。

ライフサイクルの制御方法

onMount を使用することで、以下のような制御が可能になります:

typescriptimport { atom } from 'jotai';

const connectionAtom = atom({
  status: 'disconnected',
  data: null,
});

connectionAtom.onMount = (setAtom) => {
  // 接続状態の初期化
  setAtom((prev) => ({
    ...prev,
    status: 'connecting',
  }));

  // WebSocket接続の確立
  const ws = new WebSocket('ws://localhost:8080');

  ws.onopen = () => {
    setAtom((prev) => ({
      ...prev,
      status: 'connected',
    }));
  };

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    setAtom((prev) => ({
      ...prev,
      data,
    }));
  };

  ws.onerror = (error) => {
    console.error('WebSocket エラー:', error);
    setAtom((prev) => ({
      ...prev,
      status: 'error',
    }));
  };

  // クリーンアップ処理
  return () => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.close();
    }
    setAtom((prev) => ({
      ...prev,
      status: 'disconnected',
      data: null,
    }));
  };
};

この実装により、WebSocket 接続の確立からクリーンアップまでを一元管理できます。

具体的な実装例

基本的な初期化処理

最も基本的な初期化処理の例から始めましょう:

typescriptimport { atom } from 'jotai';

// ユーザー設定atom
const userSettingsAtom = atom({
  theme: 'light',
  language: 'ja',
  notifications: true,
});

// localStorage から設定を読み込む
userSettingsAtom.onMount = (setAtom) => {
  // 初期化処理
  const savedSettings =
    localStorage.getItem('userSettings');

  if (savedSettings) {
    try {
      const parsedSettings = JSON.parse(savedSettings);
      setAtom(parsedSettings);
    } catch (error) {
      console.error('設定の読み込みに失敗しました:', error);
    }
  }

  // クリーンアップは不要(localStorage は永続化)
};

このパターンでは、atom がマウントされるタイミングで localStorage から設定を読み込み、初期化を行います。

非同期処理の管理

API 呼び出しを伴う非同期処理の管理例:

typescriptimport { atom } from 'jotai';

// API データを管理するatom
const apiDataAtom = atom({
  loading: false,
  data: null,
  error: null,
});

// API呼び出し用の関数
async function fetchApiData() {
  const response = await fetch('/api/data');
  if (!response.ok) {
    throw new Error(
      `HTTP error! status: ${response.status}`
    );
  }
  return response.json();
}

apiDataAtom.onMount = (setAtom) => {
  // ローディング状態の設定
  setAtom((prev) => ({
    ...prev,
    loading: true,
    error: null,
  }));

  // AbortController でキャンセル可能にする
  const controller = new AbortController();

  // API呼び出し
  fetchApiData()
    .then((data) => {
      setAtom((prev) => ({
        ...prev,
        loading: false,
        data,
        error: null,
      }));
    })
    .catch((error) => {
      if (error.name !== 'AbortError') {
        setAtom((prev) => ({
          ...prev,
          loading: false,
          error: error.message,
        }));
      }
    });

  // クリーンアップ:進行中のリクエストをキャンセル
  return () => {
    controller.abort();
  };
};

このパターンでは、AbortControllerを使用して、コンポーネントがアンマウントされた際に進行中の API 呼び出しをキャンセルできます。

クリーンアップ処理の実装

複雑なクリーンアップ処理が必要な場合の実装例:

typescriptimport { atom } from 'jotai';

// リアルタイムデータを管理するatom
const realtimeDataAtom = atom({
  isConnected: false,
  messages: [],
  connectionId: null,
});

realtimeDataAtom.onMount = (setAtom) => {
  let ws = null;
  let reconnectTimer = null;
  let heartbeatTimer = null;

  const connect = () => {
    ws = new WebSocket('ws://localhost:8080');

    ws.onopen = () => {
      setAtom((prev) => ({
        ...prev,
        isConnected: true,
        connectionId: Date.now(),
      }));

      // ハートビート開始
      heartbeatTimer = setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify({ type: 'ping' }));
        }
      }, 30000);
    };

    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setAtom((prev) => ({
        ...prev,
        messages: [...prev.messages, message],
      }));
    };

    ws.onclose = () => {
      setAtom((prev) => ({
        ...prev,
        isConnected: false,
      }));

      // 再接続のスケジュール
      reconnectTimer = setTimeout(connect, 5000);
    };

    ws.onerror = (error) => {
      console.error('WebSocket エラー:', error);
    };
  };

  // 初期接続
  connect();

  // クリーンアップ処理
  return () => {
    // タイマーのクリーンアップ
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
    }
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
    }

    // WebSocket接続のクリーンアップ
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.close();
    }

    // atom状態のリセット
    setAtom({
      isConnected: false,
      messages: [],
      connectionId: null,
    });
  };
};

この実装では、WebSocket 接続、ハートビート、再接続タイマーなど、複数のリソースを適切にクリーンアップしています。

ベストプラクティス

効率的なライフサイクル管理

onMount を使用する際の効率的なパターン:

typescriptimport { atom } from 'jotai';

// 効率的なライフサイクル管理の例
const efficientAtom = atom({
  data: null,
  loading: false,
  error: null,
  lastFetch: null,
});

efficientAtom.onMount = (setAtom) => {
  // 重複する処理を避けるためのフラグ
  let isActive = true;

  const fetchData = async () => {
    if (!isActive) return;

    setAtom((prev) => ({
      ...prev,
      loading: true,
      error: null,
    }));

    try {
      const data = await fetch('/api/data').then((r) =>
        r.json()
      );

      if (isActive) {
        setAtom((prev) => ({
          ...prev,
          data,
          loading: false,
          lastFetch: Date.now(),
        }));
      }
    } catch (error) {
      if (isActive) {
        setAtom((prev) => ({
          ...prev,
          error: error.message,
          loading: false,
        }));
      }
    }
  };

  // 初期データ取得
  fetchData();

  // クリーンアップ
  return () => {
    isActive = false;
  };
};

このパターンでは、isActiveフラグを使用して、アンマウント後の状態更新を防いでいます。

エラーハンドリング

適切なエラーハンドリングの実装:

typescriptimport { atom } from 'jotai';

const robustAtom = atom({
  data: null,
  loading: false,
  error: null,
  retryCount: 0,
});

robustAtom.onMount = (setAtom) => {
  const MAX_RETRIES = 3;
  let retryTimer = null;

  const fetchWithRetry = async (retryCount = 0) => {
    try {
      setAtom((prev) => ({
        ...prev,
        loading: true,
        error: null,
      }));

      const response = await fetch('/api/data');

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

      const data = await response.json();

      setAtom((prev) => ({
        ...prev,
        data,
        loading: false,
        error: null,
        retryCount: 0,
      }));
    } catch (error) {
      console.error('データ取得エラー:', error);

      if (retryCount < MAX_RETRIES) {
        setAtom((prev) => ({
          ...prev,
          loading: false,
          error: `エラーが発生しました。再試行中... (${
            retryCount + 1
          }/${MAX_RETRIES})`,
          retryCount: retryCount + 1,
        }));

        // 指数バックオフで再試行
        const delay = Math.pow(2, retryCount) * 1000;
        retryTimer = setTimeout(() => {
          fetchWithRetry(retryCount + 1);
        }, delay);
      } else {
        setAtom((prev) => ({
          ...prev,
          loading: false,
          error: `データの取得に失敗しました: ${error.message}`,
          retryCount,
        }));
      }
    }
  };

  // 初期データ取得
  fetchWithRetry();

  // クリーンアップ
  return () => {
    if (retryTimer) {
      clearTimeout(retryTimer);
    }
  };
};

この実装では、エラーが発生した場合の再試行機能と、指数バックオフによる適切な間隔での再試行を実装しています。

まとめ

onMount を使用した atom のライフサイクル管理は、React 開発において以下のような大きなメリットをもたらします:

コードの可読性向上

  • 初期化処理とクリーンアップ処理が一箇所にまとまる
  • 状態管理のロジックが明確になる

メモリリーク防止

  • 自動的なクリーンアップ処理により、メモリリークを防止
  • リソース管理が簡潔になる

パフォーマンス向上

  • 必要な時だけ初期化処理が実行される
  • 不要な処理の削減

開発者体験の改善

  • 複雑な依存関係の管理が不要
  • テストが書きやすくなる

onMount を活用することで、より堅牢で保守性の高い React アプリケーションを構築できます。特に、API との連携や外部リソースの管理において、その真価を発揮します。

皆さんも、次のプロジェクトで onMount を活用して、エレガントな状態管理を実現してみてください。きっと、開発効率と品質の向上を実感していただけることでしょう。

関連リンク