エラーの内容
ブラウザのタブが急激に重くなり、コンソールには同じ行が何百回も繰り返し表示されます:
Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
Reactは内部の再レンダリング制限(通常50回のネストされた更新)に達し、ブラウザのクラッシュを防ぐためにこの警告を表示します。コンポーネントが無限ループに陥っている状態です。
発生原因
このループは非常にシンプルな構造を持っています:
- コンポーネントがレンダリングされる
useEffectが実行され、setStateを呼び出す- 状態の変化が再レンダリングをトリガーする
- 再レンダリングにより
useEffectが再び実行される - ステップ2に戻る — 永遠に繰り返す
実際のケースの95%は以下の3つの根本原因に集約されます:
- 依存配列の欠如 — 配列がない場合、エフェクトは例外なくすべてのレンダリング後に実行される
- 依存配列内のオブジェクトまたは配列 — レンダリングのたびに新しい参照が生成されるため、Reactの浅い比較は常に変化を検出する
- エフェクト内での無条件の状態更新 — 依存配列が正しく設定されていても、常に
setStateを呼び出すと回避できない
ケース別の修正方法
ケース1:依存配列の欠如
配列が全くないのは初心者に最もよく見られるミスです。エフェクトはすべてのレンダリングをトリガーとして扱います。
// ❌ 壊れている — すべてのレンダリング後に実行される
useEffect(() => {
setCount(count + 1);
});
// ✅ 修正済み — マウント時に一度だけ実行される
useEffect(() => {
setCount(1);
}, []);
空の[]は省略可能な装飾ではありません — 「一度だけ実行して停止する」とReactに伝える約束です。
ケース2:レンダリングのたびに再生成されるオブジェクトまたは配列の依存
JavaScriptは関数が実行されるたびにメモリ上に新しいオブジェクトを生成します。{ page: 1 }と{ page: 1 }という2つのオブジェクトは参照として等しくありません — Reactは異なるものとして認識するため、エフェクトが再実行されます。
// ❌ 壊れている — optionsはレンダリングのたびに新しい参照を取得する
const options = { page: 1, limit: 20 };
useEffect(() => {
fetchData(options);
setData(result);
}, [options]); // 常に「変更あり」と判定される
2つのクリーンな解決策:
// ✅ オプションA:オブジェクトをコンポーネントの外部に移動する
const OPTIONS = { page: 1, limit: 20 }; // 一度だけ生成され、変更されない
function MyComponent() {
useEffect(() => {
fetchData(OPTIONS);
}, []);
}
// ✅ オプションB:useMemoで安定化させる
const options = useMemo(() => ({ page: 1, limit: 20 }), []);
useEffect(() => {
fetchData(options);
}, [options]); // 参照が安定した
ケース3:レンダリングのたびに再生成される関数の依存
関数もオブジェクトと同じ参照の問題を抱えています。コンポーネント本体内で関数を定義すると、レンダリングのたびに新しい関数が生成されます。
// ❌ 壊れている — fetchUserはレンダリングのたびに新しい関数になる
const fetchUser = () => {
fetch('/api/user').then(r => r.json()).then(setUser);
};
useEffect(() => {
fetchUser();
}, [fetchUser]); // 常にトリガーされる
安定した参照を得るためにuseCallbackでラップします:
// ✅ 修正済み
const fetchUser = useCallback(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []); // 一度だけ生成される
useEffect(() => {
fetchUser();
}, [fetchUser]); // 安定した参照になった
ケース4:ガード条件なしの状態更新
依存配列はあなた自身のミスから守ってくれません。依存関係として列挙されている状態を無条件に設定すると、完全なループが形成されます。
// ❌ 壊れている — itemsは依存関係であり、かつ無条件に設定される
useEffect(() => {
setItems([...items, newItem]); // itemsを設定 → 再レンダリング → エフェクトが再実行
}, [items]);
// ✅ 修正済み — 関数型更新を使用し、newItemのみに依存する
useEffect(() => {
if (newItem) {
setItems(prev => [...prev, newItem]);
}
}, [newItem]);
関数型の形式prev => [...prev, newItem]は現在の状態を内部で読み取ります — itemsを依存関係として列挙する必要は全くありません。
ケース5:データ取得と状態更新のループ
これは経験豊富な開発者でも陥りがちな落とし穴です。これから設定しようとしている状態変数を依存配列に入れることが問題です:
// ❌ 壊れている — userは依存関係だが、setUserはフェッチのたびにuserを変更する
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId, user]); // userがここで問題
// ✅ 修正済み — userIdが変更されたときのみフェッチする
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
修正の確認方法
- Chrome DevToolsを開き、Consoleタブを選択する
- ページをハード再読み込みする(Ctrl+Shift+R / Cmd+Shift+R)
Maximum update depth exceededの警告が消えていることを確認する- React DevToolsを開き、Componentsタブを確認する — コンポーネントが常時再レンダリングで点滅していないことを確認する
- NetworkタブでAPIコールがループしていないことを確認する(50回以上ではなく、1回のリクエストのみが表示されるはず)
簡易診断チェックリスト
useEffectに依存配列はありますか?- コンポーネント本体内で定義されたオブジェクトまたは配列が依存関係になっていますか?
- コンポーネント本体内で定義された関数が依存関係になっていますか?
- 設定しようとしている状態変数が依存関係としても列挙されていますか?
- 状態の更新は条件付きですか、それとも常に実行されますか?
早期に問題を検出するESLintルール
eslint-plugin-react-hooksはCreate React AppとNext.jsにデフォルトで含まれています。カスタム設定を使用している場合は手動で追加してください:
npm install eslint-plugin-react-hooks --save-dev
// .eslintrc
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
exhaustive-depsルールは、ブラウザに到達する前の記述時点で、不足または誤った依存関係にフラグを立てます。完璧ではありませんが、上記のパターンを含む明らかなケースの多くを検出できます。

