React useEffectで無限ループを防ぐ3つのパターン

useEffectの無限ループは、依存配列の設定ミスや状態更新のタイミングの誤解が原因です。この記事では、無限ループが発生する具体的な原因と、すぐに実装できる3つの解決パターンを紹介します。

useEffectの無限ループが起きる仕組み

React useEffectの無限ループは、以下のシナリオで発生します:

  • 依存配列(dependency array)を指定しない
  • 依存配列に毎回異なるオブジェクト参照を含める
  • useEffect内で依存配列に含まれる状態を更新している

ブラウザの開発者ツールのコンソールで「Warning: useEffect has a missing dependency」というワーニングが出ていれば、ほぼ確実に無限ループの原因があります。

パターン1:依存配列を明示的に指定する

最も一般的な原因は、依存配列を省略することです。依存配列なしでは、レンダリングのたびにuseEffectが実行されてしまいます。

問題のあるコード


import React, { useState, useEffect } from 'react';

export const ProblematicComponent = () => {
  const [count, setCount] = useState(0);
  const [data, setData] = useState(null);

  // ❌ 依存配列がない = 毎回実行される
  useEffect(() => {
    console.log('Effect running');
    setData({ value: count });
    // このコンポーネントのレンダリング → useEffect実行 → setData
    // → 再レンダリング → useEffect実行 の無限ループ
  });

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};
  

解決策


import React, { useState, useEffect } from 'react';

export const FixedComponent = () => {
  const [count, setCount] = useState(0);
  const [data, setData] = useState(null);

  // ✅ 依存配列を指定:countが変わった時だけ実行
  useEffect(() => {
    console.log('Effect running with count:', count);
    setData({ value: count });
  }, [count]); // 依存配列にcountを指定

  return (
    <div>
      <p>Count: {count}</p>
      <p>Data: {data?.value}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};
  

依存配列にcountを指定することで、countが変わる時だけuseEffectが実行されます。初回マウント時に実行され、その後はcountが変わる度に実行される安定的な挙動になります。

パターン2:オブジェクト参照の変動を防ぐ

依存配列にオブジェクトを直接入れるのは避けてください。毎回新しいオブジェクト参照が作成され、値が同じでも「異なる」とみなされ、無限ループの原因になります。

問題のあるコード


export const ProblematicObjectComponent = () => {
  const [data, setData] = useState(null);

  // ❌ 毎レンダリング時に新しいオブジェクトが作成される
  const config = { apiUrl: 'https://api.example.com' };

  useEffect(() => {
    console.log('Fetching with config:', config);
    // APIコール処理
    setData({ fetched: true });
  }, [config]); // configは毎回新しい参照なので、常に実行される

  return <div>{data?.fetched ? 'Loaded' : 'Loading'}</div>;
};
  

解決策1:useMemoでオブジェクトをメモ化


import React, { useState, useEffect, useMemo } from 'react';

export const FixedWithMemoComponent = () => {
  const [data, setData] = useState(null);

  // ✅ useMemoでオブジェクト参照を安定化
  const config = useMemo(() => ({
    apiUrl: 'https://api.example.com'
  }), []); // 依存配列が空 = 初回マウント時のみ作成

  useEffect(() => {
    console.log('Fetching with config:', config);
    setData({ fetched: true });
  }, [config]); // configの参照が安定しているので、マウント時のみ実行

  return <div>{data?.fetched ? 'Loaded' : 'Loading'}</div>;
};
  

解決策2:プリミティブ値を依存配列に含める


export const FixedWithPrimitivesComponent = () => {
  const [data, setData] = useState(null);
  const [apiUrl] = useState('https://api.example.com');

  // ✅ オブジェクトではなく、必要なプリミティブ値のみ依存配列に含める
  useEffect(() => {
    console.log('Fetching from:', apiUrl);
    setData({ fetched: true });
  }, [apiUrl]); // 文字列は参照の心配がない

  return <div>{data?.fetched ? 'Loaded' : 'Loading'}</div>;
};
  

パターン3:クリーンアップ関数で状態更新を制御する

非同期処理後の状態更新が無限ループの原因になることがあります。コンポーネントがアンマウントされた後の状態更新を防ぎましょう。

問題のあるコード


export const ProblematicAsyncComponent = () => {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // ❌ 非同期処理中にコンポーネントがアンマウントされても
    // setDataが実行される可能性がある
    fetch('https://api.example.com/data')
      .then(res => res.json())
      .then(json => {
        setData(json); // ここでアンマウント後に実行されるとメモリリーク警告
        setError(null);
      })
      .catch(err => setError(err));
  }, []);

  return <div>{data ? JSON.stringify(data) : 'Loading'}</div>;
};
  

解決策:クリーンアップ関数とフラグを使用


export const FixedAsyncComponent = () => {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // ✅ マウント状態を追跡するフラグを作成
    let isMounted = true;

    fetch('https://api.example.com/data')
      .then(res => res.json())
      .then(json => {
        if (isMounted) {
          setData(json);
          setError(null);
        }
      })
      .catch(err => {
        if (isMounted) {
          setError(err);
        }
      });

    // クリーンアップ関数:アンマウント時にフラグを変更
    return () => {
      isMounted = false;
    };
  }, []);

  return <div>{data ? JSON.stringify(data) : 'Loading'}</div>;
};
  

実装時の推奨プラクティス

useCallbackとの組み合わせ

関数を依存配列に含める場合、useCallbackでメモ化することで無限ループを防げます。


import React, { useState, useEffect, useCallback } from 'react';

export const WithCallbackComponent = () => {
  const [data, setData] = useState(null);

  // ✅ useCallbackで関数を安定化
  const fetchData = useCallback(async () => {
    const response = await fetch('https://api.example.com/data');
    const json = await response.json();
    setData(json);
  }, []);

  useEffect(() => {
    fetchData();
  }, [fetchData]); // fetchDataの参照が安定している

  return <div>{data ? 'Data loaded' : 'Loading'}</div>;
};
  

デバッグのヒント

無限ループが疑われる場合、以下のデバッグテクニックが有効です:


useEffect(() => {
  console.log('Effect executed at:', new Date().toISOString());
  console.trace(); // スタックトレース表示で呼び出し元を追跡

  // 処理...
}, [/* 依存配列 */]);
  

コンソールに短時間に大量のログが出力されれば、無限ループが確定です。

使うべき場面と避けるべき場面

使うべき場面 避けるべき場面
APIデータ取得(マウント時のみ) 毎フレーム実行が必要な処理
特定の状態に応じた副作用 同期的な計算処理
イベントリスナー登録 レンダリングに直結する値の計算

よくある質問

依存配列を空配列[]にすると、マウント時のみuseEffectが実行され、再レンダリング時には実行されません。一方、依存配列を省略すると、毎回のレンダリング時に実行されます。ほぼすべての場合、空配列か特定の値を指定すべきです。

いいえ、このワーニングは無視しないでください。本来は依存配列に含めるべき値が含まれていないことを示しています。ただし、意図的に除外する場合は// eslint-disable-next-line react-hooks/exhaustive-depsでコメントし、理由を記載してください。

useReducerはuseEffectの依存配列の問題を根本的には解決しません。ただし、複雑な状態更新ロジックの場合、useReducerで状態管理を明確化することで、無限ループの原因を特定しやすくなります。

まとめ

  • useEffectの無限ループは、依存配列の指定ミスや参照の変動が主な原因
  • 必ず依存配列を明示的に指定し、必要な値だけを含める
  • オブジェクト参照の変動を防ぐため、useMemoやuseCallbackを活用
  • 非同期処理後の状態更新は、クリーンアップ関数でマウント状態を管理
  • ESLintの「exhaustive-deps」ルールに従い、意図的な除外は明記する

これら3つのパターンをマスターすれば、useEffectの無限ループはほぼ確実に防げます。実装時は、依存配列の内容をコンポーネントの意図と照らし合わせることが重要です。

← 前の記事git bisectで効率的にバグの原因コミットを特定する方法
K
AWS・Python・生成AIを専門とするソフトウェアエンジニア。AI・クラウド・開発ワークフローの実践ガイドを執筆しています。詳しく見る →