依存配列を「書かない」という名の爆弾:useEffect完全無欠の制御とパフォーマンス最適化
こんにちは。日々、数百万人がアクセスする巨大なReactアプリケーションのコードベースと格闘しているフロントエンド・アーキテクトだ。
Reactの hooks が世に出てからというもの、私たちは「状態の変化」と「UIの同期」という永遠の課題に対して、非常に洗練された武器を手に入れた。そう、`useEffect`だ。しかし、この一見シンプルに見えるAPIの内部挙動、特に依存配列(dependencies array)を完全に省略した場合のメカニズムを正しく理解しているエンジニアは、驚くほど少ない。
「とりあえず動くからいいか」と、依存配列を書き忘れたり、あえて省略したりしていなかい? そのコード、ブラウザのメインスレッドを窒息させ、メモリリークの温床となり、果ては非同期の競合(Race Condition)による致命的なデータ破損を引き起こしている可能性が高い。
今回は、あえて依存配列を省略したときにReactの内部で何が起きているのか、ブラウザエンジンの挙動やV8のメモリ管理、そして実務の現場で私たちがどうやってこの「魔物」を調教しているのかについて、徹底的に深掘りしていこう。
—
1. 依存配列の省略:その時、Reactとブラウザの裏で何が起きているのか
まず、基本のおさらいから始めよう。`useEffect`の第2引数に何も渡さない、つまり以下のようなコードを書いたとき、Reactはその副作用を「すべてのレンダリングの後」に実行する。
// 依存配列を省略した、最も危険な香り漂うコード
useEffect(() => {
console.log(“毎回のレンダリング後に走るよ!”);
});
このとき、Reactのファイバーアーキテクチャ(Fiber Architecture)のライフサイクルにおいて、何が起きているのか?
Reactは、コミットフェーズ(Commit Phase)の完了後、DOMが画面にペイントされた「後」に、非同期でこのエフェクトをスケジュールし、実行する。問題は、これが「マウント時だけでなく、stateが1つ更新されるたび、親コンポーネントが再レンダリングされるたび、果ては無関係なpropsが渡されるたびに」実行されるという点だ。
メインスレッドの占有とJank(カクつき)の発生
JavaScriptはシングルスレッドで動作する。すべてのレンダリング後に重い処理(例えば、DOMの強制的な計測、複雑な計算、外部APIへのリクエストなど)を走らせた場合、ブラウザの描画エンジンはレイアウト計算やペイントのタイミングを奪われ、ユーザーのスクロールやアニメーションが盛大にカクつく(Jankの発生)。
シニアエンジニアであれば、「UIの滑らかさ(60fps / 120fpsの維持)」がユーザー体験の命であることを知っているはずだ。依存配列の省略は、自らメインスレッドに重りを括り付けるようなものなのだ。
—
2. 破滅へのシナリオ:無限ループとメモリリーク
依存配列の省略がもたらす最大のバグは、「状態の更新」と「エフェクトの実行」の無限ループだ。
以下の実例を見てほしい。ありがちな「データをフェッチして状態を更新する」コードだが、依存配列が抜けている。
import React, { useState, useEffect } from ‘react’;
const InfiniteLoopDisaster = () => {
const [data, setData] = useState(null);
const [count, setCount] = useState(0);
// 依存配列がないため、render -> effect -> setState -> render の無限地獄へ突入する
useEffect(() => {
// 擬似的なAPIコール
fetchData().then(res => {
setData(res);
});
}); // <-- 依存配列がない!
return (
Count: {count}
);
};
このコードの何がヤバいか。ボタンを押して `count` が変わる。当然、コンポーネントが再レンダリングされる。すると、`useEffect` が発火し、`fetchData` が走る(今回はデータ更新の副作用はないが、もしここで `setData` を呼んでいたら……)。
仮にデータフェッチではなく、エフェクト内で `setState` を呼んでいなくても、親からの再レンダリングのたびに、無駄な副作用が無限に走り続けることになる。
メモリリークとイベントリスナーの亡霊
さらに恐ろしいのは、クリーンアップ関数を伴わない購読(Subscription)やイベントリスナーの登録だ。
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth);
};
// ウィンドウのリサイズイベントを登録
window.addEventListener(‘resize’, handleResize);
// クリーンアップがない、あるいは依存配列のせいで毎回再登録される
});
依存配列を省略した場合、このコンポーネントが再レンダリングされるたびに、古いイベントリスナーが解放されないまま、新しいイベントリスナーがDOMのwindowオブジェクトに何重にも追加されていく。結果としてメモリリークが発生し、タブ全体のメモリ消費量が跳ね上がり、最終的にブラウザがクラッシュする。
—
3. 実務で「依存配列を省略せざるを得ないケース」はあるのか?
結論から言えば、「コンポーネントのライフサイクル全体を通じて、本当に毎回のレンダリングごとに必ず実行されなければならない極めて特殊な処理」以外、依存配列を省略する正当な理由は現代のReact開発においては存在しない。
では、その「特殊なケース」とは何か?
例えば、以下のようなデザインシステムの低レイヤーな計測フックや、アニメーションフレーム毎のDOMプロパティの同期などが挙げられる。
import { useEffect, useLayoutEffect, useRef } from ‘react’;
// DOMの正確なサイズや位置を毎回のレンダリング後に追跡するケース
// (※通常は useLayoutEffect を使うべきだが、コンセプトの例として)
const useTrackDOMMutation = (callback) => {
// 依存配列を意図的に省略し、毎回のコミット後に最新のDOM状態をキャプチャする
useEffect(() => {
callback();
}); // すべてのレンダリング後に強制実行
};
しかし、これすらも「本当に毎回のレンダリングが必要か?」を疑うべきだ。多くの場合、特定の値の変化に絞るべきであり、依存配列を空(`[]`)にして「マウント時のみ」にするか、監視すべき変数を適切に配列に詰めるべきだ。
—
4. 堅牢なアーキテクチャのためのベストプラクティス
では、私たちはどのようにしてこの問題に対処し、強固なコードベースを維持すべきか。アーキテクトとしての実践的な指針をいくつか授けよう。
① `eslint-plugin-react-hooks` を絶対的信頼のおける神と崇めよ
現代のReact開発において、ESLintのルール(`react-hooks/exhaustive-deps`)を無視することは、シートベルトをせずにF1マシンでサーキットを走るようなものだ。
コンパイル時(ビルド時)に依存配列の漏れを機械的に検知してくれるこの仕組みを、絶対に「警告の無効化(`// eslint-disable-line`)」で逃げないこと。もし警告が出たら、それはコードの設計(ステートの置き場所や、関数のメモ化など)に欠陥があるというシグナルだ。
② 関数のメモ化(`useCallback`)と依存関係の制御
エフェクト内で関数を呼び出す際、その関数がコンポーネントのスコープ内で定義されていると、毎回のレンダリングで関数の参照(Reference)が変わるため、依存配列に入れた途端にエフェクトが毎回走ることになる。
ここで `useCallback` の出番だ。
import React, { useState, useEffect, useCallback } from ‘react’;
const OptimizedComponent = ({ userId }) => {
const [data, setData] = useState(null);
// 依存関係(userId)が変わらない限り、関数の参照アドレスを維持する
const fetchUserData = useCallback(async () => {
const response = await fetch(`https://api.example.com/user/${userId}`);
const json = await response.json();
setData(json);
}, [userId]);
useEffect(() => {
fetchUserData();
}, [fetchUserData]); // fetchUserDataの参照が安定しているため、無駄な再実行を防げる
return
;
};
③ useRef を使った「レンダリングをトリガーしない値の追跡」
「値の変化は追いたいが、それが原因で再レンダリングやエフェクトの再実行を引き起こしたくない」という極めて高度な要件には、`useRef` が最高の相棒となる。`useRef` の `.current` プロパティを書き換えても、Reactのレンダリングライフサイクルはトリガーされないからだ。
—
5. まとめ:プロフェッショナルとしての誇り
`useEffect` の依存配列を省略するという行為は、Reactのリアクティブなパラダイムに対する真っ向からの挑戦であり、多くの場合、バグへの片道切符だ。
私たちが目指すべきなのは、「動けばいいや」の精神で作られた脆弱なコードではない。ブラウザの挙動を深く理解し、メモリ効率を極限まで高め、どのような負荷がかかっても滑らかに動作する、美しく堅牢なアーキテクチャだ。
今日からコードベースを開き、依存配列の抜け落ちた `useEffect` がないか、静的解析ツールを走らせてみてほしい。そして、Reactのライフサイクルを完全に掌握した真のスペシャリストとして、誇り高きコードを書き上げよう。

コメント