`useEffect`の空配列 `[]` という甘い罠:マウント時実行神話の解体と、本番環境で生き残るためのアーキテクチャ
こんにちは。日々、数百万人が往来する巨大なReactアプリケーションのコードベースを解体し、パフォーマンスの最適化とメモリリークの狩猟に明け暮れているチーフアーキテクトだ。
Reactを触り始めて最初に覚える呪文の一つに、`useEffect(…, [])`がある。「コンポーネントがマウントされた時に一度だけAPIを叩くやつね」と、誰もが疑いもなく書いてきたはずだ。
だが、少し立ち止まって考えてみてほしい。
その「一度だけ」という思い込みこそが、Concurrent Mode(並行レンダリング)が当たり前になった現代のReactにおいて、メモリリーク、競合状態(Race Condition)、そして予測不可能なバグの温床になっているとしたら?
今回は、依存配列に空配列 `[]` を渡したときのブラウザ内部での挙動を解体し、シニアエンジニアが知るべき「本当の副作用の制御方法」について深く潜っていく。
—
1. `[]` は「マウント時」を保証するものではない
まず、大前提を叩き込んでおこう。
`useEffect` の第2引数に空配列 `[]` を渡すとき、私たちは無意識に「このエフェクトはコンポーネントが画面に生まれるときに1回だけ走る」と思っている。しかし、Reactのコアメンテナたちが口を酸っぱくして言っているように、依存配列は「タイミング」を指定するものではなく、「リアクティブな値の変更を監視するスコープ」でしかない。
空配列を渡すという行為は、「この副作用は、コンポーネント内のいかなる状態(State)やプロパティ(Props)の変化からも独立している」とReactに宣言しているに過ぎない。
StrictModeという名の現実チェック
特に、開発環境で `StrictMode` を有効にしていると、コンポーネントは以下のライフサイクルをたどる。
1. マウント(Mount)
2. 即座にアンマウント(Unmount)
3. 再度マウント(Remount)
もしあなたが `[]` を信じ切って、クリーンアップ関数を書かずにグローバルなイベントリスナーの登録やWebSocketの接続を行っていたらどうなるか。開発環境で確実にコネクションが二重に張られ、メモリリークの警報が鳴り響くことになる。
Reactは、将来的にコンポーネントがユーザーの操作なしに画面から一時的に退避し、再び戻ってくるようなアグレッシブなキャッシュ機構(Offscreen APIなど)を本番環境にも導入しようとしている。その時、`[]` を「一生に一度の初期化処理」として書かれたコードは、ことごとく破綻する運命にあるのだ。
—
2. APIの初期取得(Data Fetching)における「競合状態(Race Condition)」の呪縛
`[]` の最も一般的なユースケースとして挙げられるのが、画面表示時のデータフェッチだ。しかし、ここにはシニアであっても見落としがちな致命的な罠が潜んでいる。
以下のコードを見てほしい。一見、完璧に動くように見えるだろう。
import React, { useState, useEffect } from ‘react’;
type User = {
id: number;
name: string;
};
export const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState
const [loading, setLoading] = useState
useEffect(() => {
let isCancelled = false; // クリーンアップ用のフラグ
const fetchUser = async () => {
try {
setLoading(true);
// userIdが変わっても、[]だとこのエフェクトは再実行されない!
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
// アンマウント済み、あるいは古いリクエストであれば状態を更新しない
if (!isCancelled) {
setUser(data);
}
} catch (error) {
if (!isCancelled) {
console.error(‘Failed to fetch user:’, error);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchUser();
return () => {
// クリーンアップ関数でフラグを立てる
isCancelled = true;
};
}, []); // ⚠️ 警告: userIdが変更されてもフェッチが走らないため、実際には [userId] にすべきケース
if (loading) return
;
if (!user) return
;
return
;
};
なぜこのコードは危険なのか?
もしこのコンポーネントの親が `userId` を動的に変更する設計だった場合、`useEffect` の依存配列に `[]` を指定しているため、`userId` が変わってもAPIリクエストは再実行されない。結果として、画面に表示されているIDと、フェッチして表示されるユーザー情報が乖離する致命的なバグが生まれる。
「いや、うちは `userId` は変わらないから大丈夫だ」と思ったあなた。
では、ユーザーが素早く画面を行き来したことで、遅いネットワーク経由のリクエストAが完了する前に、リクエストBが走り、古いリクエストAの結果が後から返ってきて最新の画面を上書きしてしまう現象(競合状態:Race Condition)はどう防ぐのか?
非同期処理におけるレスポンスの返却順序は、ネットワークの機嫌次第だ。これを制御するためには、単に `[]` を置くだけではなく、クリーンアップ時に関係性を断ち切る仕組みが不可欠となる。
—
3. 堅牢なアーキテクチャのためのベストプラクティス
では、私たちはこの `[]` という諸刃の剣をどのように扱い、堅牢なアプリケーションを構築すべきなのか。現場で実践している具体的なアプローチを共有しよう。
A. 「一度きりの処理」と「データの同期」を厳密に分離する
マウント時の初期化(例:ロガーの初期化、グローバルなイベントのバインド)と、propsやstateに依存したデータ取得を混同してはならない。
データフェッチに関しては、もはや手動で `useEffect` を書く時代は終わりつつある。TanStack Query (React Query) や SWR などのサーバーサイド・ステート管理ライブラリを導入し、キャッシュの無効化、重複リクエストの排除、競合状態の自動解決をライブラリ側に委譲するのが、モダンフロントエンドのデファクトスタンダードだ。
// React Queryを用いた、依存配列の苦悩から解放された実装
import { useQuery } from ‘@tanstack/react-query’;
export const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const { data: user, isLoading, error } = useQuery({
queryKey: [‘user’, userId], // userIdが変更されると、自動的に新しいキーでフェッチが走る
queryFn: async () => {
const res = await fetch(`https://api.example.com/users/${userId}`);
if (!res.ok) throw new Error(‘Network response was not ok’);
return res.json();
},
staleTime: 1000 60 5, // 5分間はキャッシュを新鮮とみなす
});
if (isLoading) return
;
if (error) return
;
return
;
};
これを見れば一目瞭然だが、`useEffect` の依存配列のバグに頭を悩ませる時間は完全に消え去る。
B. どうしても `[]` で副作用を制御する場合のイディオム
それでもなお、純粋なDOM操作や、コンポーネントライフサイクルに紐づく一度限りのサブスクリプションを実装せざるを得ない場面はある。その場合は、以下の要件を満たしたコードを書くことだ。
1. AbortControllerを活用した非同期処理のキャンセル
2. StrictModeを意識した厳密なクリーンアップ
import React, { useState, useEffect } from ‘php’; // 嘘、Reactです
export const MetricsDashboard: React.FC = () => {
const [metrics, setMetrics] = useState
useEffect(() => {
// ブラウザ標準の AbortController でネットワークリクエストを物理的に中断する
const controller = new AbortController();
const { signal } = controller;
async function loadMetrics() {
try {
const response = await fetch(‘/api/heavy-metrics’, { signal });
const data = await response.json();
setMetrics(data);
} catch (error: any) {
if (error.name !== ‘AbortError’) {
console.error(‘Fetch aborted or failed:’, error);
}
}
}
loadMetrics();
// クリーンアップ関数
return () => {
// コンポーネントがアンマウントされた瞬間、通信を強制キャンセルする
controller.abort();
};
}, []); // ここが空なのは、「コンポーネント生存中の初撃のメトリクス取得」という明確な意図がある場合のみ許される
return
;
};
このコードでは、仮にユーザーがコンポーネントのマウント直後に別のページへ遷移したとしても、`AbortController` がバックグラウンドでの無駄なCPUサイクルと帯域の消費を完全に防ぎ、メモリリークの芽を摘み取る。
—
まとめ:`[]` は逃げの道具ではない
`useEffect(…, [])` は、Reactの宣言的パラダイムにおける「例外的な脱出口(Escape Hatch)」だ。
それを「とりあえずエラーが出ないから」「最初にデータを取れればいいから」という安易な理由で多用しているうちは、あなたのアプリケーションは常に潜在的なバグの地雷原の上を歩いていることと同義である。
- 本当にその処理はコンポーネントのライフサイクルに依存しているか?
- StrictModeや再マウントのテストをクリアできるクリーンアップが書かれているか?
- 非同期の競合(Race Condition)やメモリリークへの対策は講じられているか?
これらの問いに自信を持って「Yes」と答えられるコードベースこそが、数年後もスケールし続ける堅牢なプロダクトの土台となる。
ギークとしての誇りにかけて、安直な `[]` の乱用はやめにしよう。明日のコードは、もっと美しく、もっと理にかなったアーキテクチャで構築されているはずだ。

コメント