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

やあ、調子はどうだい?現場で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` が必要になるエッジケースについて話そうか。また現場で会おう!

コメント

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