useEffectの無限ループ:ブラウザを沈黙させる「依存配列の罠」と、その緻密な解剖学
やあ。日夜、コンポーネントのライフサイクルとDOMの調停に頭を悩ませているフロントエンド・ギークの仲間たちよ。
Reactのコードベースをレビューしていて、最も背筋が凍る瞬間はどんなときだろうか? 私は迷わずこう答える。「ファン!とファンの回転数が急上昇し、ファンの音がジェット機の離陸音のようになった瞬間、そしてChromeのタブが静かに『Aw, Snap!(メモリ不足)』と白目を剥いた瞬間だ」と。
原因の多くは決まっている。`useEffect` の依存配列(dependency array)のコントロールを誤り、「レンダリング $\rightarrow$ 副作用の実行 $\rightarrow$ 状態の更新 $\rightarrow$ 再レンダリング」 という、美しくも残酷な無限のサーキット(自爆ループ)に踏み込んでしまったのだ。
今回は、この無限ループがブラウザのメインスレッドで物理的に何を引き起こしているのかという内部挙動から、React DevToolsをメスとして使った患部の特定、そして現場で使える堅牢なアーキテクチャ的回避策まで、徹底的に深掘りしていこう。
—
1. なぜ無限ループは起きるのか? —— Reactの「変更検知」と「同期」のメカニズム
まず、頭をガチガチのリアクティブ・マシーンに切り替えてほしい。`useEffect` は、Reactの「純粋なレンダリング世界」と、DOMやネットワークといった「不純な現実世界」を同期させるためのエスケープハッチだ。
ここに、よくある「やってはいけない」アンチテーゼのコード片がある。
import { useState, useEffect } from ‘vaild-react’; // 架空の私の脳内React
function InfinitePain() {
const [data, setData] = useState
// 致命的なバグ:dataを更新しているのに、依存配列にdataを含めている
useEffect(() => {
// サーバーからデータを取得したと仮定(実際はもっと重い処理)
const fetchedData = [1, 2, 3, Math.random()];
setData(fetchedData);
}, [data]); // <-- ここが地獄への入り口
return
;
}
何が起きているか、脳内でV8エンジンのコールスタックをトレースしてみよう。
1. 初期マウント: コンポーネントが初めて描画される(Render Phase)。`data` は空配列 `[]`。
2. 副作用の発火: コミットフェーズが完了し、Reactは `useEffect` をスケジュールして実行する。
3. 状態の更新: 副作用内で `setData()` が呼ばれ、Reactに新しい状態のスケジュールを要求する。これにより強制的に再レンダリングの予約が入る。
4. 再レンダリング: 新しい `data` が生成される。ここで、JavaScriptの参照型の比較(`Object.is`)が火を吹く。配列は毎回新規作成(`[]` !== `[]`)されるため、Reactは「依存配列の値が変わった!」と判定する。
5. ループの完成: ステップ2に戻る。
この一連のサイクルが、ブラウザのメインスレッドが悲鳴を上げるまで(あるいはV8のヒープメモリが枯渇するまで)一瞬の隙もなく高速回転する。これが無限ループの正体だ。
—
2. React DevToolsを用いた「患部」の外科的特定
現場で「なんかこのコンポーネントだけCPU使用率が100%になるぞ」というバグに遭遇したとき、やみくもに `console.log` を仕込むのはアマチュアのやることだ。プロのアーキテクトは、React DevTools のProfilerとProfilerの「Why did this render?」機能、そしてブラウザのPerformanceタブを冷徹に使いこなす。
手順 A: Profilerによるレンダリング爆発の視覚化
1. 開発者ツールの [React] -> [Profiler] タブを開く。
2. 録画ボタン(Record)を押し、問題のコンポーネントをマウント(または操作)する。
3. 停止すると、Flamegraph(炎のグラフ)に、「ミリ秒単位で無限にレンダリングを繰り返しているコンポーネント」が赤〜黄色の塔のようにそびえ立っているのが確認できる。
4. 該当コンポーネントをクリックし、「Ranked」タブや「Why did this render?」を見る。そこに 「Props changed: data」 や 「State changed: …」 が毎フレーム発生しているログの嵐が確認できれば、それが犯人だ。
手順 B: ブラウザの Performance タブ(Call Tree)でメインスレッドの窒息を確認
無限ループはJavaScriptの実行スレッドを完全に占有するため、ブラウザはユーザーインタラクション(クリックやスクロール)を受け付けなくなる(いわゆる「画面が固まる」状態)。
Performanceタブで記録を取ると、「Long Task」の赤い警告バーが永遠に続き、親である `Timer` や `Recalculate Style` が一切挟まる隙間がないグラフが描かれる。これで無限ループの確定診断を下す。
—
3. 堅牢なアーキテクチャによる回避策とベストプラクティス
では、どうやってこの悪夢を断ち切るべきか?
「依存配列から変数を外せばいいんだろ?」という安易なアプローチ(ESLintの警告を `// eslint-disable-next-line` で力技で黙らせるなど)は、バグの時限爆弾を未来の自分にプレゼントするようなものだ。絶対に避けるべき。
真に堅牢なエンジニアリングアプローチを見ていこう。
対策 1: 依存配列のミニマライズ(プリミティブ値への分解)
オブジェクトや配列をそのまま依存配列に入れるから、参照の不一致でループが起きる。識別子(ID)や特定のプリミティブなフラグだけを依存させよ。
import { useState, useEffect } from ‘react’;
// 良い例:ユーザーIDが変わった時「のみ」フェッチを走らせる
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isMounted = boolCheck; // クリーンアップ用のフラグ(競合対策)
fetchUser(userId).then(res => {
if (isMounted) setUser(res);
});
return () => {
isMounted = false; // クリーンアップで非同期の競合(Race Condition)を防ぐ
};
}, [userId]); // 依存するのはプリミティブな userId のみ!
return
;
}
対策 2: `useCallback` と参照の安定化
関数を `useEffect` の中で呼び出す必要がある場合、その関数自体がレンダリングのたびに再生成されていると、依存配列が「変化した」と誤認してループの引き金になる。
import { useState, useEffect, useCallback } from ‘react’;
function SearchComponent({ query }: { query: string }) {
const [results, setResults] = useState
// 関数の参照をメモ化して安定させる
const fetchResults = useCallback(async (searchQuery: string) => {
const data = await api.search(searchQuery);
setResults(data);
}, []); // 依存関係が空なら、この関数インスタンスは永続化される
useEffect(() => {
fetchResults(query);
}, [query, fetchResults]); // queryが変わったときだけ実行される
return
- {results.map(r =>
- {r}
)}
;
}
対策 3: 状態更新関数(Updater Function)の活用
「現在の状態をベースに次の状態を計算したい」という理由だけで、状態変数を依存配列に入れがちだ。しかし、`setState` にコールバック(Updater Function)を渡せば、状態変数自体を依存関係から排除できる。
import { useState, useEffect } from ‘react’;
function Counter() {
const [count, setCount] = useState(0);
// やってはいけない例:
// useEffect(() => { setCount(count + 1); }, [count]); // 無限ループ!
// 正しい例:
useEffect(() => {
const timer = setInterval(() => {
// 現在のstateを引数で受け取るため、依存配列に count を含める必要がない
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(timer); // クリーンアップを忘れないこと!
}, []); // 依存配列は空で完璧に機能する
return
;
}
—
4. チーフアーキテクトからの提言:エフェクト中毒からの脱却
`useEffect` は非常に強力だが、同時にReactアプリケーションのコードベースを複雑化させる「諸刃の剣」だ。
実際のところ、シニア・スペシャリストの視点から言わせてもらえば、「 `useEffect` を書かなくて済むなら、それが最高のアーキテクチャである」。
- データのフェッチなら、TanStack Query (React Query) や SWR などの専用ライブラリにキャッシュとライフサイクル管理を委譲する。
- 派生状態(Derived State)の計算なら、`useEffect` でわざわざstateを同期させるのではなく、レンダリング中に直接計算するか、`useMemo` を使う。
`useEffect` の依存配列をコントロールするということは、Reactの宣言的UIの裏側にある「副作用の時系列管理」を手なずけるということだ。機械的なルール暗記ではなく、レンダリングパイプラインとメモリ上の参照関係を脳内で立体的にイメージしながら、美しく堅牢なコードを組み上げてほしい。
さあ、エディタに戻ろう。君の書くコンポーネントが、今日も軽快にレンダリングされることを祈っている。

コメント