開発者がよく無視する警告
何度も見たことがあるはずです:
React Hook useEffect has a missing dependency: 'someVariable'. Either include it or remove the dependency array. react-hooks/exhaustive-deps
よくある対応は?// eslint-disable-next-line を上に追加して先に進む。これは悪手です。この警告が存在するのは、依存関係の欠落が実際の微妙なバグを引き起こすからです。特定のユーザー操作の連続後にのみ本番環境で表面化し、古いクロージャまで追跡するのに何時間もかかるようなバグです。
具体的なバグのシナリオ
少なくとも十数個のコードベースで見てきたバグパターンを紹介します:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []); // ← ESLint が警告: 'userId' が欠落しています
}
問題なさそうに見えますよね?では、親が別の userId を渡した場合を想像してください。たとえば、ユーザーが別のプロフィールを表示しようとクリックしたとき。エフェクトは再実行されません。古いデータが画面に残ります。コンポーネントは別人の情報を静かに表示し続けます。
ESLint はこれを検出していました。空の配列 [] は React に「一度だけ実行して、二度と実行しない」と伝えていますが、エフェクトは明らかに userId に依存して動作しています。
依存配列が存在する理由
React は依存配列内の値が変化するたびに useEffect を再実行します。エフェクトが実際に使用している値を省略すると、React は再実行すべきタイミングを知ることができません。エフェクトはその変数の古いスナップショットを捕捉したまま、更新されることはありません。これがクラシックなクロージャバグです。
react-hooks/exhaustive-deps ESLint ルールはエフェクト本体を静的に解析し、配列に含めるべきすべての変数にフラグを立てます。煩わしいと感じるかもしれません。しかし、ほぼ常に正しい指摘です。
3つの修正方法
修正 1: 欠落している依存関係を追加する
10回中9回は、答えは単純です:追加するだけです。
// 修正前(バグあり)
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []);
// 修正後(正しい)
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]);
これで userId が変わるたびに、エフェクトが新鮮なデータを再取得します。それが本来望む動作です。
修正 2: 関数を依存関係にする場合 — useCallback を使う
ここからが少し複雑になります。コンポーネント本体内で定義された関数は、毎回のレンダーで再生成されます。それを最初に安定させずに依存配列に追加すると、無限ループが発生します。
// 問題: fetchData はレンダーごとに新しい参照 → 無限ループ
function SearchPage({ query }) {
const fetchData = () => fetch(`/api/search?q=${query}`);
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // レンダーごとに fetchData が変わる!
}
関数を useCallback でラップして、自身の依存関係が変わったときにのみ変化するようにします:
function SearchPage({ query }) {
const fetchData = useCallback(() => {
return fetch(`/api/search?q=${query}`);
}, [query]); // 安定した参照; query が変わったときのみ更新される
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // これで安全
}
修正 3: 関数をエフェクト内部に移動する
その関数が1つのエフェクトにしか使われないなら、useCallback を省略してエフェクト内部で定義しましょう:
useEffect(() => {
const fetchData = () => fetch(`/api/search?q=${query}`);
fetchData().then(data => setResults(data));
}, [query]); // query だけ — シンプルで明快
間接参照が減ります。読みやすくなります。多くの場合、これが正しい選択です。
空の配列が実際に正しい場合
マウント時のみの動作が本当に必要な場合もあります。WebSocket の設定、サードパーティ SDK の初期化、グローバルイベントリスナーの登録などです。エフェクトにリアクティブな依存関係がない場合、[] は正しいです:
useEffect(() => {
const socket = io('wss://example.com');
socket.on('message', handleMessage);
return () => socket.disconnect();
}, []); // 正しい: 一度接続し、アンマウント時に切断する
注意点:handleMessage が state や props を読み取る場合、古いクロージャの問題は依然として存在します。ref を使ってハンドラーが最新の値にアクセスできるようにしつつ、ソケットを再生成しないようにします:
const handleMessageRef = useRef(handleMessage);
useEffect(() => {
handleMessageRef.current = handleMessage;
});
useEffect(() => {
const socket = io('wss://example.com');
socket.on('message', (msg) => handleMessageRef.current(msg));
return () => socket.disconnect();
}, []);
無効化コメント — 意識的に使う、安易に使わない
eslint-disable が常に間違いというわけではありません。理解していない警告を黙らせるために使うときが間違いです。意図的に使う場合は問題ありません:
useEffect(() => {
// 意図的: マウント時の値のみをログに記録し、変更のたびには記録しない
console.log('Initial count:', count);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
なぜそうするのかを説明するコメントを必ず追加してください。インシデント対応中に深夜11時にこのコードを見返す未来の自分が感謝するでしょう。黄色い波線を消すためだけにルールを無効化しないでください。
確認手順
- ESLint の出力を確認する: ファイルを保存します。エディタと
npx eslint src/の両方から警告が消えるはずです。 - 動的なケースをテストする: エフェクトが
userIdのような props に依存している場合、実際にその props を変更してください。別のユーザーに移動したり、フィルターを切り替えたりして、エフェクトが新鮮なデータで再実行されることを確認します。 - 無限ループを監視する: 関数を依存配列に追加しましたか?ネットワークタブを開いて、リクエストが連続して発行されていないか確認してください。もし発行されているなら、修正 2 または修正 3 を適用してください。
- テストスイートを実行する: props の変更で再実行されるようになったエフェクトは、古いクロージャのせいで静かにパスしていたテストを露わにすることがあります。それらのテストは間違ったものをテストしていました。修正してください。
クイックリファレンス
- プリミティブ(string、number、boolean)の欠落 → 配列に追加する
- オブジェクトまたは配列の欠落 → 安定していることを確認する(
useState、useRef、またはuseMemoから取得); そうでなければメモ化する - コンポーネント本体からの関数の欠落 → エフェクト内部に移動するか、
useCallbackでラップする - props からの関数の欠落 → 呼び出し元で
useCallbackでラップするか、エフェクト内部に移動する - 本当にマウント時のみのエフェクト → 空の配列が正しい; lint ルールを無効化する場合は説明コメントを追加する

