React の useEffect 完全理解 — 依存配列の罠
概要
useEffect は React のフックの中でも最も使う機会が多く、そして最も誤解されやすいものです。特に「依存配列(第2引数)に何を書くか」で挙動が大きく変わり、初心者がハマる原因の大半はここに集中しています。
この記事では、useEffect の基本的な考え方から、依存配列にまつわる代表的な罠、そしてその回避方法までを、動く最小コードとともに整理します。
useEffect の基本モデル
useEffect は「レンダー後に実行される副作用」を宣言するためのものです。ポイントは「いつ実行されるかは依存配列が決める」ことです。
useEffect(() => {
// 副作用(データ取得・購読・DOM操作など)
return () => {
// クリーンアップ(購読解除など)
};
}, [dep1, dep2]); // 依存配列
依存配列の指定によって挙動は3パターンに分かれます。
// 1. 毎レンダー後に実行
useEffect(() => { /* ... */ });
// 2. 初回マウント時のみ実行
useEffect(() => { /* ... */ }, []);
// 3. dep が変化したときだけ実行
useEffect(() => { /* ... */ }, [dep]);
重要なのは、React が依存配列の各要素を「前回のレンダーの値」と Object.is で比較している点です。この仕組みを理解していないと、以降の罠にすべてハマります。
罠1: 依存配列に嘘をつく(値を書かない)
effect の中で使っている値を、依存配列に書かないケースです。
function Counter({ step }) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + step);
}, 1000);
return () => clearInterval(id);
}, []); // ← step が抜けている
return <p>{count}</p>;
}
このコードは初回マウント時の step を閉じ込めてしまい、あとで step が変わっても古い値を参照し続けます。これが stale closure(古い値の閉じ込め) です。exhaustive-deps ルールはこれを警告します。まずは素直に依存へ入れましょう。
}, [step]);
罠2: オブジェクト・配列・関数を依存に入れる
依存配列は Object.is 比較のため、毎レンダーで新しい参照が作られるオブジェクト・配列・関数は毎回「変わった」と判定されます。
function Search({ query }) {
const options = { query, limit: 20 }; // 毎レンダー新しい参照
useEffect(() => {
fetchData(options);
}, [options]); // 毎レンダー実行されてしまう
}
対策A: プリミティブに分解する
useEffect(() => {
fetchData({ query, limit: 20 });
}, [query]);
対策B: useMemo / useCallback で参照を安定させる
const options = useMemo(() => ({ query, limit: 20 }), [query]);
useEffect(() => {
fetchData(options);
}, [options]);
const handleFetch = useCallback(() => {
fetchData(query);
}, [query]);
useEffect(() => {
handleFetch();
}, [handleFetch]);
罠3: クリーンアップを忘れて非同期が競合する
データ取得でクリーンアップを書かないと、古いリクエストが後で解決して新しい結果を上書きする レースコンディション が起きます。
useEffect(() => {
let active = true;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (active) setUser(data);
});
return () => {
active = false;
};
}, [userId]);
AbortController を使えばリクエスト自体をキャンセルできます。
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [userId]);
罠4: 関数更新で依存を減らす
state を「前の値から計算する」場合は、関数更新形式を使うことで state 自体を依存から外せます。
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // count を依存に入れなくてよい
}, 1000);
return () => clearInterval(id);
}, []);
setCount(count + 1) だと count を依存に入れる必要がありますが、setCount((c) => c + 1) なら不要です。
確認方法
- ESLint の
react-hooks/exhaustive-depsを有効にすると、依存漏れはほぼ検出できます。 - 無限ループを疑うときは effect 内に
console.logを仕込み、実行回数を確認します。 - React 18 の Strict Mode は開発時に effect を2回実行します。これはクリーンアップの不備をあぶり出す仕様で、本番では1回です。
注意点
- 警告を消すために依存配列に嘘を書かない。
- レンダー中に計算できる派生値は effect ではなく通常の変数(必要なら
useMemo)で求める。 - ユーザー操作の処理は effect ではなくイベントハンドラに書く。
まとめ
useEffect の罠の大半は「依存配列は Object.is 比較である」という一点に集約されます。使う値は素直に依存に入れ、参照が変わるものは分解か memo 化し、非同期はクリーンアップで競合を防ぎ、state 更新は関数形式で依存を減らす。まずは exhaustive-deps を有効にし、警告に素直に従うところから始めるのが最短の理解ルートです。
