useEffectを書くたびに無限ループを踏んでいた時期が僕にはある。コンポーネントをマウントした瞬間、ブラウザのタブがフリーズして、コンソールに何万行ものログが流れる。あの絶望感は忘れられない。
この記事では、useEffectの無限ループが起きる仕組みを根本から理解して、よくある3つのパターンそれぞれの正しい対処法を説明する。stale closureの落とし穴と、デバッグの実践的なテクニックも合わせて紹介する。読み終わったあとは「なぜこうなるのか」が腑に落ちているはずだ。
useEffectが無限ループする原因は?
useEffectの動作を一言で言うと「依存配列の値が変わるたびに実行される関数」だ。ここでの「変わるたびに」という部分が無限ループの根本原因になる。
- コンポーネントがレンダリングされる
- useEffectが実行される
- useEffect内でsetStateが呼ばれる
- stateが更新されるので再レンダリングが起きる
- useEffectがまた実行される → 1に戻る
このサイクルが止まらないのが無限ループだ。依存配列(第2引数の配列)がこのサイクルを制御するブレーキになるが、書き方を間違えるとブレーキが効かなくなる。
依存配列の3つの形
// 1. 毎回実行(依存配列なし)
useEffect(() => { /* 毎レンダリング後に実行 */ });
// 2. マウント時のみ(空配列)
useEffect(() => { /* マウント時に1回だけ実行 */ }, []);
// 3. 依存値が変わったときだけ実行
useEffect(() => { /* count が変わったときに実行 */ }, [count]);
Reactは依存配列の各要素を Object.is で比較する。プリミティブ値(数値・文字列・真偽値)は値が同じなら同じとみなされる。オブジェクトや配列は、中身が同じでも毎レンダリングで新しい参照が生まれるので「変わった」と判断される。これが後述する「参照の罠」の正体だ。
依存配列を書き忘れると何が起きる?
最も多い原因は依存配列そのものを書き忘れることだ。
❌ 悪い例
function Counter() {
const [count, setCount] = useState(0);
// 依存配列がない → 毎レンダリング後に実行される
useEffect(() => {
setCount(count + 1); // setStateが呼ばれる → 再レンダリング → また実行
});
return <div>{count}</div>;
}
依存配列を省略すると、このeffectは毎レンダリング後に実行される。setCountが呼ばれるたびにレンダリングが起き、またeffectが走る。無限ループの完成だ。
✅ 正しい例
function Counter() {
const [count, setCount] = useState(0);
// マウント時に1回だけ実行したいなら空配列
useEffect(() => {
setCount(1); // 初期値を設定するだけなのでこれでいい
}, []);
// countに依存するなら明示的に書く
useEffect(() => {
document.title = `カウント: ${count}`;
}, [count]); // countが変わったときだけタイトルを更新
return <div>{count}</div>;
}
ESLintプラグイン eslint-plugin-react-hooks を使えば、依存配列の書き忘れを自動検出できる。exhaustive-deps ルールを有効にすることを強くすすめる。
{
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
オブジェクト・配列を依存配列に入れるとなぜループする?
プリミティブ値は値で比較されるが、オブジェクトや配列は参照で比較される。この違いが「参照の罠」を生む。
❌ 悪い例(オブジェクト)
function UserProfile() {
const [user, setUser] = useState({ name: "太郎", age: 20 });
const [profile, setProfile] = useState(null);
// options はレンダリングのたびに新しいオブジェクトが生成される
const options = { method: "GET", headers: { "Content-Type": "application/json" } };
useEffect(() => {
fetchProfile(user, options).then(setProfile);
}, [user, options]); // options の参照が毎回変わる → 無限ループ
return <div>{profile?.bio}</div>;
}
options はレンダリングのたびにコンポーネント関数が再実行されるときに新しいオブジェクトとして生成される。中身が同じでも { method: "GET" } === { method: "GET" } は false だ。Reactはこれを「変化した」と判断してeffectを再実行し、setProfileが呼ばれ、再レンダリングが起き……という無限ループになる。
✅ 正しい例(useMemo / コンポーネント外に移動)
// 解決策1: コンポーネント外に定数として定義
const FETCH_OPTIONS = { method: "GET", headers: { "Content-Type": "application/json" } };
function UserProfile() {
const [user, setUser] = useState({ name: "太郎", age: 20 });
const [profile, setProfile] = useState(null);
useEffect(() => {
fetchProfile(user, FETCH_OPTIONS).then(setProfile);
}, [user]); // user はstateなので参照が安定している
return <div>{profile?.bio}</div>;
}
// 解決策2: useMemoで参照を安定させる
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState(null);
// useMemo で依存する値が変わったときだけ新しいオブジェクトを生成
const options = useMemo(
() => ({ method: "GET", headers: { "Authorization": `Bearer ${userId}` } }),
[userId] // userId が変わったときだけ options を再生成
);
useEffect(() => {
fetchProfile(options).then(setProfile);
}, [options]); // options の参照が安定している
return <div>{profile?.bio}</div>;
}
❌ 悪い例(配列)
function TagList() {
const [items, setItems] = useState([]);
// tags はレンダリングのたびに新しい配列
const tags = ["react", "hooks"];
useEffect(() => {
fetchItems(tags).then(setItems);
}, [tags]); // 毎回新しい配列 → 無限ループ
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
✅ 正しい例(配列)
// 解決策1: コンポーネント外に定数として定義
const DEFAULT_TAGS = ["react", "hooks"];
function TagList() {
const [items, setItems] = useState([]);
useEffect(() => {
fetchItems(DEFAULT_TAGS).then(setItems);
}, []); // タグが固定なら空配列でOK
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// 解決策2: プリミティブに変換してから依存配列に入れる
function TagList({ tags }: { tags: string[] }) {
const [items, setItems] = useState([]);
// 配列をJSON文字列に変換 → プリミティブ比較になる
const tagsKey = tags.join(",");
useEffect(() => {
fetchItems(tags).then(setItems);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsKey]); // 文字列なので値比較が効く
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
fetchしてsetStateすると無限ループになる?
データフェッチでのuseEffectは最もよく使うパターンだが、間違えやすい。
❌ 悪い例
function PostList() {
const [posts, setPosts] = useState([]);
const [filter, setFilter] = useState("all");
useEffect(() => {
fetch(`/api/posts?filter=${filter}`)
.then(res => res.json())
.then(data => {
setPosts(data); // setPostsが呼ばれる
});
}, [posts, filter]); // posts を依存配列に入れてしまっている!
// setPosts → posts が変わる → effect再実行 → 無限ループ
return (
<div>
<button onClick={() => setFilter("popular")}>人気順</button>
<ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</div>
);
}
posts を依存配列に入れているのが間違いだ。fetchの結果を posts に入れると、posts が変化してeffectが再実行される。
✅ 正しい例
function PostList() {
const [posts, setPosts] = useState([]);
const [filter, setFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false; // クリーンアップ用フラグ
setLoading(true);
setError(null);
fetch(`/api/posts?filter=${filter}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) { // アンマウント済みなら setState しない
setPosts(data);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true; // クリーンアップ: コンポーネントがアンマウントされたらフラグを立てる
};
}, [filter]); // filter だけに依存。posts は入れない
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error}</div>;
return (
<div>
<button onClick={() => setFilter("popular")}>人気順</button>
<button onClick={() => setFilter("all")}>すべて</button>
<ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</div>
);
}
ポイントは2つだ。
- 依存配列にfetchの結果(
posts)を入れない。fetchを実行するトリガーになる値(filter)だけを入れる - クリーンアップ関数でキャンセルフラグを立てる。コンポーネントがアンマウントされたあとにsetStateが呼ばれる「メモリリーク」を防げる
AbortControllerを使うモダンな書き方
function PostList() {
const [posts, setPosts] = useState([]);
const [filter, setFilter] = useState("all");
useEffect(() => {
const controller = new AbortController();
fetch(`/api/posts?filter=${filter}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setPosts(data))
.catch(err => {
if (err.name !== "AbortError") {
console.error(err);
}
});
return () => {
controller.abort(); // クリーンアップ: リクエストをキャンセル
};
}, [filter]);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
AbortController を使うとfetchリクエスト自体をキャンセルできる。フラグ方式よりもネットワークリソースを節約できるのでこちらの方が好ましい。
stale closureとは何か?どう対策する?
stale closure(古いクロージャ)は、useEffectが古いstateやpropsの値を参照し続ける問題だ。無限ループとは少し違うが、useEffectを使ううえで必ず理解しておきたい。
❌ 悪い例
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // このcountは常に0のまま(stale closure)
}, 1000);
return () => clearInterval(id);
}, []); // 空配列なので count の最新値が参照できない
return <div>{count}</div>; // 永遠に1のまま
}
空配列にしたので count はマウント時の値(0)でクロージャがキャプチャされる。1秒ごとに setCount(0 + 1) が呼ばれるので、表示は常に 1 のままだ。
✅ 正しい例(関数型更新)
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 関数型更新: 最新のstateを引数で受け取る
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空配列のままでOK
return <div>{count}</div>; // 1, 2, 3, 4 ... と正しくカウントアップ
}
setCount(prev => prev + 1) の形にすると、Reactが最新のstateを prev として渡してくれる。クロージャに依存しないので stale closureの問題を回避できる。
useRefで最新値にアクセスする
state以外のもの(コールバック関数など)を最新状態に保ちたいときは useRef を使う。
function SearchBox({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState("");
const onSearchRef = useRef(onSearch);
// onSearch が変わるたびにrefを更新
useEffect(() => {
onSearchRef.current = onSearch;
}, [onSearch]);
useEffect(() => {
const id = setTimeout(() => {
// ref経由で常に最新の onSearch を呼べる
onSearchRef.current(query);
}, 300);
return () => clearTimeout(id);
}, [query]); // onSearch を依存配列に入れなくていい
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="検索..."
/>
);
}
useEffectの無限ループをどうデバッグする?
無限ループの原因を特定するための実践的な方法を紹介する。
console.countで実行回数を確認
function DebugComponent() {
const [count, setCount] = useState(0);
const [data, setData] = useState(null);
useEffect(() => {
console.count("effect実行"); // 何回実行されたか追跡
console.log("依存値:", count, data);
fetchData().then(result => setData(result));
}, [count, data]); // ← これが問題: dataを依存配列に入れている
return <div>{count}</div>;
}
コンソールに effect実行: 1、effect実行: 2 ... と増え続けるなら無限ループが確定だ。console.log で表示された依存値を確認して、どれが変化し続けているかを特定する。
React DevTools Profilerでボトルネックを可視化
React DevTools(ブラウザ拡張機能)のProfilerタブを使うと、どのコンポーネントが何回レンダリングされているかを視覚的に確認できる。
- React DevToolsをインストールする(Chrome拡張)
- Profilerタブを開く
- 録画を開始してコンポーネントをマウントする
- 短時間で大量のレンダリングが起きているコンポーネントを探す
Why Did You Renderライブラリ
@welldone-software/why-did-you-render を使うと、不要な再レンダリングの原因を自動的にコンソールに報告してくれる。開発環境だけに適用する。
// src/wdyr.ts(開発用)
import React from "react";
if (process.env.NODE_ENV === "development") {
const whyDidYouRender = require("@welldone-software/why-did-you-render");
whyDidYouRender(React, {
trackAllPureComponents: true,
});
}
// 監視したいコンポーネント
function MyComponent() {
// ...
}
MyComponent.whyDidYouRender = true; // このコンポーネントだけ監視
コンソールに「propsが変わっていないのに再レンダリングされた」「どのプロパティが変わったか」が表示される。
useEffectの依存配列を一時的にコメントアウトして切り分ける
useEffect(() => {
fetchData().then(setData);
// }, [data, filter]); // 元の依存配列
}, [filter]); // dataを外してみる → 無限ループが止まればdataが原因
怪しい依存を1つずつ外して、どれが原因かを二分探索で特定するのが地道だが確実だ。
まとめ
useEffectの無限ループは、依存配列の仕組みを理解すれば防げる。
| パターン | 原因 | 解決策 |
|---|---|---|
| 依存配列の書き忘れ | 毎レンダリングでeffect実行 | [] か適切な依存を明示する |
| オブジェクト・配列を依存配列に | 毎回新しい参照が生成される | コンポーネント外に出す / useMemo を使う |
| fetchの結果をそのまま依存配列に | fetchのたびに再実行される | fetchを呼ぶトリガーだけを依存配列に入れる |
| stale closure | 古い値をクロージャがキャプチャ | 関数型更新 prev => / useRef を使う |
まず eslint-plugin-react-hooks を設定して、依存配列の警告を見逃さないようにする。それだけで大半の問題は未然に防げる。
無限ループが起きたら console.count で実行回数を確認し、React DevTools Profilerで再レンダリングを可視化する。原因の依存値を特定したら、今回紹介した4パターンのどれに当てはまるかを照合すれば、ほぼ必ず解決策が見つかる。