【テクニカル・上級編】 useEffectによる無限ループの発生原因と回避策 – React実践ガイド

やあ、現場の最前線でコードと格闘している同志諸君。今日もブラウザのファンを全開にして、Reactのレンダリングループと戦っていることだろう。

チーフアーキテクトとして数多の凄惨な現場を見てきた私が、今日君たちに語りたいのは、React開発における「静かなる死神」、すなわちuseEffectによる無限ループについてだ。

公式ドキュメントをなぞるだけなら誰でもできる。だが、なぜ我々は経験を積んでもなお、この罠に足を取られるのか。それは、Reactの「宣言的UI」という美しい理想の裏側に、JavaScriptの「参照の同一性」と「クロージャ」という冷徹な現実が横たわっているからだ。

今日は、その泥臭いメカニズムを解剖し、二度とブラウザをフリーズさせないための「真の処方箋」を提示しよう。

—

1. 無限ループの正体:エントロピーの増大

無限ループは、単純なミスから生まれるのではない。「副作用が状態を更新し、その状態の更新が再び同じ副作用をトリガーする」という、意図しないフィードバック・ループが完成した瞬間に発生する。

典型的な「自爆」コード

まずは、もっとも原始的な自爆例を見てみよう。

import React, { useState, useEffect } from ‘react’;

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

useEffect(() => {
// 依存配列がないため、レンダリングのたびに実行される
// 実行されると setState が走り、再レンダリングを誘発する
// → 無限ループへ
setCount(count + 1);
}, [count]); // countに依存しているが、中でcountを更新している

return

{count}

;
};

「こんなミスはしない」と思うだろう? だが、実務の複雑なロジックの中にこれが隠れると、途端に見えなくなる。例えば、APIから取得したデータをそのまま state に詰め込み、その state を監視して別の fetch を飛ばすようなケースだ。

—

2. 参照の罠:目に見えない「変化」

上級者であっても陥るのが、オブジェクトや配列の参照同一性に起因するループだ。JavaScriptにおいて、`[] === []` は `false` である。この単純な事実が、Reactの `Object.is` による比較アルゴリズムと衝突したとき、悲劇が起こる。

参照の不一致によるループ

const DataFetcher = ({ params }) => {
const [data, setData] = useState(null);

// params が親コンポーネントでレンダリングのたびに「インライン生成」されていると
// useEffect は「値が変わった」と判断し、無限に fetch を繰り返す
useEffect(() => {
const fetchData = async () => {
const result = await api.get(‘/data’, params);
setData(result);
};
fetchData();
}, [params]); // paramsが { id: 1 } のようなオブジェクトだと、毎回新造されるため危険

return

…

;
};

この場合、`DataFetcher` を使う側の親コンポーネントが `` と書いていれば、一発アウトだ。レンダリングのたびに新しいオブジェクトが生成され、`useEffect` は「新しいパラメータが来た!」と勘違いして暴走を始める。

—

3. 回避のための銀の弾丸:関数型アップデートとuseRef

このループを断ち切るためのアーキテクチャ的アプローチはいくつかあるが、私が現場で徹底させているのは以下の3点だ。

A. 関数型アップデート(Functional Updates)

`setCount(count + 1)` のように現在の値に依存するのではなく、セッター関数にコールバックを渡す。これにより、`useEffect` の依存配列からその状態を排除できる。

useEffect(() => {
const timer = setInterval(() => {
// countを直接参照せず、更新関数を使うことで
// 依存配列に count を入れる必要がなくなる
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 依存配列は空で済む

B. useRef による「脱獄」

「最新の値は必要だが、その値の変化で副作用を再発火させたくない」という場面がある。その時は `useRef` だ。`ref.current` の変更はレンダリングをトリガーしない。これは、Reactのリアクティブ・システムから一時的にエスケープするための、安全な「裏口」だ。

C. 依存配列の「純粋性」を保つ

もしオブジェクトを依存配列に入れる必要があるなら、`useMemo` でメモ化するか、あるいはオブジェクトそのものではなく、その中の プリミティブな値(stringやnumber) を個別に配列に入れるべきだ。

// 悪い例: [options]
// 良い例: [options.id, options.query]

—

4. 究極のデバッグ手法:why-did-you-render

もし無限ループに陥り、どこで参照が壊れているのか見当がつかないなら、根性で `console.log` を埋めるのはやめよう。
`why-did-you-render` のようなライブラリを導入し、どのプロパティが「値は同じなのに参照が変わったのか」を特定するんだ。

また、React DevTools の “Record why each component rendered” オプションを有効にしろ。レンダリングの理由が「Hook 1 changed」と出れば、犯人はその `useEffect` だ。

—

5. 設計思想:そもそも useEffect は「同期」のためのものだ

最後に、アーキテクトとして最も重要な教訓を伝えよう。
多くのエンジニアが `useEffect` を「ライフサイクルメソッド(componentDidMountの代わり)」として使っているが、それは誤解の始まりだ。

`useEffect` の本質は、「Reactの外の世界(DOM、ネットワーク、外部ライブラリ)と、Reactの状態を同期させること」にある。

  • データの加工のために `useEffect` を使っていないか?(それは `useMemo` か、レンダリング中の計算で済むはずだ)
  • ユーザーのイベント(クリック等)をきっかけに副作用を起こしていないか?(それは `useEffect` ではなく、イベントハンドラの中に書くべきだ)

「副作用の中で状態を更新する」という行為自体が、本来は例外的な処置であることを肝に銘じてほしい。

—

結論

無限ループは、Reactが君のコードに対して「同期の定義が矛盾しているぞ」と突きつけている警告文だ。
依存配列を誤魔化すために `eslint-disable` を使うのは、エンジニアとしての敗北を意味する。

1. 関数型アップデートで依存を減らす。
2. 参照の同一性を `useMemo` / `useCallback` で守る。
3. そもそもその処理は `useEffect` である必要があるかを疑う。

この3原則を徹底すれば、君のアプリケーションは岩のように堅牢になるだろう。

ハッピー・ハッキング。ブラウザのクラッシュに怯える夜は、もう終わりだ。

コメント

タイトルとURLをコピーしました