Reactの無限ループエラー|useEffect依存配列の罠

ReactのuseEffectで発生する無限ループエラーの原因と対処法を解説。依存配列の正しい使い方とよくある間違いを初心者向けに説明します。

Learning Next 運営
24 分で読めます

みなさん、React開発でこんな恐怖を体験したことはありませんか?

「コンソールに同じメッセージが止まらずに表示される」
「ページが重くなってブラウザが固まる」
「useEffectが無限に実行されてしまう」

これらは、useEffectの依存配列が原因で起こる無限ループエラーです。 初心者がよく遭遇する問題ですが、理解してしまえば必ず解決できます。

この記事では、無限ループが起こる仕組みから具体的な対処法まで、わかりやすく解説します。 もう無限ループに悩まされることはありませんよ!

無限ループって何?なぜ起こるの?

まずは、useEffectで無限ループが発生する仕組みを理解しましょう。 実は、とてもシンプルな原理なんです。

useEffectの基本的な仕組み

useEffectは、依存配列の値が変更された時に実行されます。

const [count, setCount] = useState(0);

useEffect(() => {
  console.log('countが変わりました!');
}, [count]); // countが変わったら実行される

この仕組み自体は正常ですが、間違った使い方をすると無限ループになります。

無限ループが起こる流れ

問題のあるコードを見てみましょう。

// ❌ 危険!無限ループのコード
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1); // ここでcountを変更
}, [count]); // countが変わったら再実行

このコードは以下の流れで無限ループになります。

  1. 初回: countが0、useEffectが実行される
  2. useEffect内: setCount(1)でcountが1に変更
  3. 再実行: countが変わったのでuseEffectが再び実行
  4. 無限: setCount(2)でcountが2に変更、また再実行...

これが延々と続くため、ブラウザが重くなってしまいます。

オブジェクトでも起こる無限ループ

オブジェクトや配列でも同じ問題が起こります。

// ❌ これも無限ループになる
const [user, setUser] = useState({});

useEffect(() => {
  console.log('userが変わりました');
  // 何も変更していないように見えるが...
}, [user]); // userオブジェクトの参照が毎回変わる

Reactはオブジェクトを参照で比較するため、新しいオブジェクトは「変更された」と判断されます。

よくある無限ループパターンを見てみよう

実際の開発でよく遭遇する無限ループパターンをご紹介します。 どれも「あるある」なケースなので、要注意です。

パターン1: API呼び出しでの無限ループ

最もよくある間違いがこれです。

// ❌ 問題のあるコード
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
  const fetchPosts = async () => {
    setLoading(true);
    const response = await fetch('/api/posts');
    const data = await response.json();
    setPosts(data);
    setLoading(false);
  };
  
  fetchPosts();
}, [posts, loading]); // ここが問題!

このコードの問題点:

  • setPosts(data): postsが変更される
  • 依存配列に[posts, loading]: postsが変わったので再実行
  • 無限ループ: API呼び出しが永遠に続く

✅ 正しい書き方

const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
  const fetchPosts = async () => {
    setLoading(true);
    const response = await fetch('/api/posts');
    const data = await response.json();
    setPosts(data);
    setLoading(false);
  };
  
  fetchPosts();
}, []); // 空の依存配列で初回のみ実行

依存配列を空にすることで、コンポーネントマウント時に1回だけ実行されます。

パターン2: オブジェクトを依存配列に入れる

// ❌ 問題のあるコード
const [userInfo, setUserInfo] = useState({});

useEffect(() => {
  const fetchUserData = async () => {
    const response = await fetch('/api/user');
    const data = await response.json();
    setUserInfo(data);
  };
  
  fetchUserData();
}, [userInfo]); // オブジェクト全体を依存配列に入れている

✅ 正しい書き方

const [userInfo, setUserInfo] = useState({});

useEffect(() => {
  const fetchUserData = async () => {
    const response = await fetch('/api/user');
    const data = await response.json();
    setUserInfo(data);
  };
  
  fetchUserData();
}, []); // 初回のみ実行

// または、特定のプロパティのみ監視
useEffect(() => {
  if (userInfo.id) {
    console.log('ユーザーIDが設定されました:', userInfo.id);
  }
}, [userInfo.id]); // オブジェクト全体ではなく、必要な値のみ

パターン3: 関数を依存配列に入れる

// ❌ 問題のあるコード
const [data, setData] = useState([]);

const fetchData = async () => {
  const response = await fetch('/api/data');
  const result = await response.json();
  setData(result);
};

useEffect(() => {
  fetchData();
}, [fetchData]); // 関数が毎回新しく作成される

関数は毎回新しく作成されるため、依存配列に入れると無限ループになります。

✅ 正しい書き方(方法1: 関数をuseEffect内に移動)

const [data, setData] = useState([]);

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch('/api/data');
    const result = await response.json();
    setData(result);
  };
  
  fetchData();
}, []); // 空の依存配列

✅ 正しい書き方(方法2: useCallbackを使用)

const [data, setData] = useState([]);

const fetchData = useCallback(async () => {
  const response = await fetch('/api/data');
  const result = await response.json();
  setData(result);
}, []); // useCallbackで関数をメモ化

useEffect(() => {
  fetchData();
}, [fetchData]); // メモ化された関数なら安全

パターン4: 配列の状態更新

// ❌ 問題のあるコード
const [items, setItems] = useState([]);

useEffect(() => {
  if (items.length === 0) {
    setItems(['item1', 'item2', 'item3']);
  }
}, [items]); // 配列が変更されるたびに実行

✅ 正しい書き方

const [items, setItems] = useState([]);

useEffect(() => {
  // 初回のみアイテムを設定
  setItems(['item1', 'item2', 'item3']);
}, []); // 空の依存配列で初回のみ実行

実際の開発シーンでの対処法

実際のプロジェクトでよく遭遇するケースと、その対処法を見てみましょう。

ユーザー情報の取得と更新

// よくあるシナリオ: ユーザーIDに基づいてユーザー情報を取得
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (error) {
        console.error('ユーザー情報の取得に失敗:', error);
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      fetchUser();
    }
  }, [userId]); // userIdが変わった時のみ実行

  if (loading) return <div>読み込み中...</div>;
  if (!user) return <div>ユーザーが見つかりません</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

この例では、userIdが変わった時のみAPIを呼び出すようにしています。 userloadingは依存配列に含めていません。

フォームの入力値監視

function ContactForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
    message: ''
  });
  const [errors, setErrors] = useState({});

  // 入力値が変わった時のバリデーション
  useEffect(() => {
    const newErrors = {};
    
    if (form.name.length > 0 && form.name.length < 2) {
      newErrors.name = '名前は2文字以上で入力してください';
    }
    
    if (form.email.length > 0 && !form.email.includes('@')) {
      newErrors.email = '有効なメールアドレスを入力してください';
    }
    
    setErrors(newErrors);
  }, [form.name, form.email]); // 特定のフィールドのみ監視

  const handleChange = (field) => (e) => {
    setForm(prev => ({
      ...prev,
      [field]: e.target.value
    }));
  };

  return (
    <form>
      <div>
        <input
          type="text"
          value={form.name}
          onChange={handleChange('name')}
          placeholder="名前"
        />
        {errors.name && <p style={{color: 'red'}}>{errors.name}</p>}
      </div>
      
      <div>
        <input
          type="email"
          value={form.email}
          onChange={handleChange('email')}
          placeholder="メールアドレス"
        />
        {errors.email && <p style={{color: 'red'}}>{errors.email}</p>}
      </div>
      
      <div>
        <textarea
          value={form.message}
          onChange={handleChange('message')}
          placeholder="メッセージ"
        />
      </div>
    </form>
  );
}

フォーム全体(form)ではなく、監視したい特定のフィールド(form.name, form.email)のみを依存配列に含めています。

検索機能の実装

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // 検索クエリが変わった時に検索を実行
  useEffect(() => {
    if (query.length === 0) {
      setResults([]);
      return;
    }

    const searchData = async () => {
      setLoading(true);
      try {
        const response = await fetch(`/api/search?q=${query}`);
        const data = await response.json();
        setResults(data);
      } catch (error) {
        console.error('検索に失敗:', error);
        setResults([]);
      } finally {
        setLoading(false);
      }
    };

    // デバウンス処理(連続入力の最適化)
    const timeoutId = setTimeout(searchData, 300);
    
    return () => clearTimeout(timeoutId);
  }, [query]); // queryが変わった時のみ実行

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="検索キーワードを入力"
      />
      
      {loading && <p>検索中...</p>}
      
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  );
}

この例では、queryが変わった時のみ検索を実行しています。 デバウンス処理も含めることで、連続入力時の無駄なAPI呼び出しを防いでいます。

無限ループを防ぐ5つの対策

無限ループを防ぐための具体的な対策をご紹介します。

1. 依存配列は最小限にする

必要最小限の値のみを依存配列に含めましょう。

// ❌ 不要な値も含める
const [user, setUser] = useState({});
const [posts, setPosts] = useState([]);

useEffect(() => {
  if (user.id) {
    fetchUserPosts(user.id);
  }
}, [user, posts]); // postsは不要

// ✅ 必要な値のみ
useEffect(() => {
  if (user.id) {
    fetchUserPosts(user.id);
  }
}, [user.id]); // user.idのみで十分

2. 初回のみ実行したい場合は空の依存配列

// 初回のみ実行したい処理
useEffect(() => {
  // 初期データの取得
  fetchInitialData();
}, []); // 空の依存配列

3. useCallbackで関数をメモ化

const [data, setData] = useState([]);

// 関数をメモ化
const fetchData = useCallback(async (id) => {
  const response = await fetch(`/api/data/${id}`);
  const result = await response.json();
  setData(result);
}, []); // 依存する値がなければ空配列

useEffect(() => {
  fetchData(1);
}, [fetchData]); // メモ化された関数なら安全

4. 条件分岐で実行を制御

const [count, setCount] = useState(0);

useEffect(() => {
  // 条件を満たす場合のみ実行
  if (count < 10) {
    const timer = setTimeout(() => {
      setCount(prev => prev + 1);
    }, 1000);
    
    return () => clearTimeout(timer);
  }
}, [count]); // countが10未満の場合のみ実行

5. refを使って値を保持

const [data, setData] = useState([]);
const prevDataRef = useRef();

useEffect(() => {
  // 前回の値と比較
  if (prevDataRef.current !== data) {
    console.log('データが更新されました');
    prevDataRef.current = data;
  }
}, [data]);

デバッグと対処法

無限ループが発生した時の調べ方と対処法をご紹介します。

1. コンソールログで確認

useEffect(() => {
  console.log('useEffectが実行されました', {
    timestamp: new Date().toISOString(),
    dependencies: { count, user, posts }
  });
  
  // 実際の処理
}, [count, user, posts]);

コンソールに大量のログが表示される場合は、無限ループが発生しています。

2. React Developer Toolsを使う

ブラウザの拡張機能「React Developer Tools」を使うと、コンポーネントの再レンダリングを可視化できます。

使い方:

  1. Chrome/Firefox に React Developer Tools をインストール
  2. 開発者ツールでComponentsタブを開く
  3. 右上の設定から「Highlight updates when components render」を有効化
  4. 無限に点滅するコンポーネントがあれば、そこで問題が発生

3. ESLintルールを設定

// .eslintrc.json
{
  "extends": ["react-app"],
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/exhaustive-deps": "warn"
  }
}

このルールを設定すると、依存配列の問題をエディタが警告してくれます。

4. useWhyDidYouUpdateフック(カスタムフック)

function useWhyDidYouUpdate(name, props) {
  const previous = useRef();
  
  useEffect(() => {
    if (previous.current) {
      const allKeys = Object.keys({...previous.current, ...props});
      const changedKeys = {};
      
      allKeys.forEach(key => {
        if (previous.current[key] !== props[key]) {
          changedKeys[key] = {
            from: previous.current[key],
            to: props[key]
          };
        }
      });
      
      if (Object.keys(changedKeys).length) {
        console.log('[why-did-you-update]', name, changedKeys);
      }
    }
    
    previous.current = props;
  });
}

// 使用例
function MyComponent(props) {
  useWhyDidYouUpdate('MyComponent', props);
  
  // コンポーネントの内容
}

このカスタムフックを使うと、どのpropsが変更されて再レンダリングが発生したかがわかります。

まとめ:無限ループはもう怖くない!

React useEffectの無限ループについて、原因から対処法まで詳しく解説しました。

無限ループの主な原因

  1. useEffect内でstateを更新して、そのstateを依存配列に含める
  2. オブジェクトや配列全体を依存配列に入れる
  3. 関数を依存配列に入れる
  4. 不適切な依存配列の設定

対策のポイント

  1. 依存配列は最小限に: 本当に必要な値のみを含める
  2. 初回のみ実行: 空の依存配列[]を使う
  3. useCallbackを活用: 関数をメモ化する
  4. 条件分岐で制御: 無限実行を防ぐ
  5. デバッグツールを活用: 問題を早期発見

実践的なアドバイス

  • API呼び出し: 基本的に空の依存配列[]を使う
  • フォーム監視: 特定のフィールドのみを依存配列に含める
  • オブジェクト監視: 必要なプロパティのみを指定する
  • ESLintルール: react-hooks/exhaustive-depsを有効化

無限ループは最初は怖く感じるかもしれませんが、仕組みを理解すれば必ず解決できます。 今回紹介した方法を使って、安全で効率的なReactアプリを作ってくださいね!

関連記事