やあ、調子はどうだい?現場でReactを書いていれば、誰もが一度は「ブラウザが固まった……」「コンソールが`Maximum update depth exceeded`のエラーで埋め尽くされた……」という、あの背筋が凍る瞬間を経験するものだ。
今日は、React開発における最大の鬼門の一つ、`useEffect`による「無限ループ」について深掘りしよう。これは単なる記述ミスというより、ReactのレンダリングサイクルとJavaScriptの参照比較(Referential Equality)の本質を理解しているかどうかが試される、非常に奥の深いテーマなんだ。
中級エンジニアの君なら、依存配列(Dependency Array)の書き方は知っているはずだ。だが、なぜそれが牙を向くのか、どうすればエレガントに回避できるのか。シニアの視点から、現場で即戦力になる知見を伝授するよ。
—
なぜ無限ループは起きるのか?:そのメカニズム
まずは敵を知ることから始めよう。`useEffect`が無限ループに陥る基本構造は、驚くほどシンプルだ。
1. Render: コンポーネントがレンダリングされる。
2. Effect: `useEffect`が実行される。
3. State Update: Effectの中で`setState`を呼び、状態を更新する。
4. Re-render: 状態が変わったので、Reactが再レンダリングをスケジュールする。
5. Back to Step 2: 再レンダリング後、依存配列の条件が合致(あるいは配列が不適切)しているため、再びEffectが実行される。
このループが、ブラウザの処理能力の限界まで超高速で繰り返されるわけだ。
ブラウザの裏側で起きていること
Reactの`useEffect`は、ブラウザが画面を描画(Paint)した後に非同期で実行される。つまり、無限ループが発生すると、ブラウザは「描画 → すぐさま再計算 → 描画」というタスクをイベントループに詰め込み続け、UIスレッドを占有してしまう。これが「画面がカクつく」「ファンが回り出す」原因だ。
—
現場で頻発する3つの「無限ループ」パターン
1. 状態更新の依存ループ(初心者卒業レベル)
最も典型的なのは、更新したい状態そのものを依存配列に入れてしまうケースだ。
// ❌ 典型的なNG例
useEffect(() => {
setCount(count + 1);
}, [count]); // countが変わるたびに実行され、実行されるたびにcountが変わる
2. オブジェクト・配列の「参照」による罠(中級者がハマるポイント)
JavaScriptにおいて、`[] === []` は `false` だ。これを知っていても、Reactの文脈で見失うエンジニアは多い。
// ❌ 危険なコード
function UserProfile() {
const [data, setData] = useState([]);
// レンダリングのたびに新しい配列(インスタンス)が生成される
const options = { priority: ‘high’ };
useEffect(() => {
// optionsが「新しいオブジェクト」と判定され、毎回実行されてしまう
fetchData(options).then(res => setData(res));
}, [options]);
}
3. 関数定義の再生成
コンポーネント内で定義した関数も、レンダリングのたびに「新しい関数」として作り直される。それをEffect内で使い、かつ依存配列に入れると、やはり無限ループの扉が開く。
—
無限ループを回避する「プロの処方箋」
さて、ここからが本題だ。どうやってこれらをスマートに解決するか。
手法A:関数型アップデート(Functional Update)を活用する
状態を更新する際、現在の値に依存しているなら、`setState(prev => prev + 1)` の形式を使おう。こうすることで、依存配列からその状態を外すことができる。
// ✅ 改善案:依存配列を空にできる
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 外部のcount変数に依存しない
}, 1000);
return () => clearInterval(timer);
}, []); // 依存配列は空でOK
手法B:`useMemo` と `useCallback` で参照を固定する
オブジェクトや関数を依存配列に入れる必要があるなら、それらの「メモ化」は必須だ。
// ✅ プロの書き方
function UserProfile({ userId }) {
const [data, setData] = useState(null);
// オブジェクトの参照を固定する
const requestConfig = useMemo(() => ({
url: `/api/user/${userId}`,
method: ‘GET’
}), [userId]); // userIdが変わらない限り、同じインスタンスを返す
useEffect(() => {
let isMounted = true;
fetch(requestConfig.url)
.then(res => res.json())
.then(json => {
if (isMounted) setData(json);
});
return () => { isMounted = false; }; // クリーンアップ関数を忘れずに
}, [requestConfig]); // requestConfigの参照が安定しているので安全
}
手法C:そもそも `useEffect` が必要か自問自答する
実は、これが最も重要だ。「propsやstateの変化に合わせて別のstateを更新する」ために `useEffect` を使うのは、多くの場合アンチパターンだ。
例えば、`firstName` と `lastName` から `fullName` を作るのに `useEffect` は要らない。レンダリング中に計算すればいい。
—
実践:デバッグの極意
もし無限ループに陥ったら、パニックにならずに以下の手順を試してくれ。
1. `console.log` の戦略的配置:
`useEffect` の直前に `console.log(“Effect Run”)` を、依存配列の各要素に `console.log({ dependency })` を仕込む。
2. `useRef` を使った比較:
「前回の値」と「今回の値」を `useRef` で保持して比較し、どの値が原因で再実行されたのかを特定する。
3. React DevTools の活用:
Profilerタブを使い、何が原因でコンポーネントが再レンダリングされたかを視覚的に確認する。
—
最後に:アーキテクトからのアドバイス
`useEffect` は「Reactのレンダリング結果を外部システム(DOM、ネットワーク、購読)と同期させるための同期メカニズム」だ。単なる「ライフサイクルメソッドの代わり」と考えると、今回のような無限ループの罠にハマりやすくなる。
依存配列を「Reactに嘘をつくための場所(Warningを消すためだけの場所)」にしないでほしい。Linter(`eslint-plugin-react-hooks`)が警告を出しているなら、それは君のコードに設計上の欠陥があるという親切なサインなんだ。
この原則をマスターすれば、君の書くコンポーネントはより堅牢で、予測可能なものになるはずだ。
次は、`useLayoutEffect` が必要になるエッジケースについて話そうか。また現場で会おう!

コメント