ReactのuseEffectの依存配列で起きる無限ループと古い値の直し方

​​React​ESLint,TypeScript,useEffect,useState

useEffect の依存配列でよくある間違いは、大きく2つに分かれます。エフェクトが止まらなくなる「無限ループ」と、エフェクトが古い値を使い続ける「古い値」です。依存配列を書き忘れた、描画のたびに作るオブジェクトを入れた、値を入れ忘れた、タイマーの中で古い state から次の値を作った、の4つを Chromium で動かし、何が起きるかと直し方を確かめました。どの間違いも、eslint-plugin-react-hooks の警告で見つかりました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 6.0.3、esbuild 0.28.2、Playwright 1.56.1、ESLint 10.13.0、eslint-plugin-react-hooks 7.1.1、typescript-eslint 8.72.0 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、Playwright で操作した Chromium で動かしました。StrictMode では包んでいません。

検証に使った「検索」

検証用に、猫を年齢で絞り込む searchCats を作りました。{ minAge } を受け取り、その年齢以上の猫の名前を20ミリ秒後に返します。呼ばれるたびに、何回目の呼び出しかを console に出します。

ts
// 検証用の「検索」。minAge 歳以上の猫の名前を、20ミリ秒後に返す
const CATS = [
  { name: 'たま', age: 3 },
  { name: 'みけ', age: 7 },
  { name: 'くろ', age: 1 },
  { name: 'しろ', age: 5 },
];

export let searchCount = 0;

export function searchCats(query: { minAge: number }): Promise<string[]> {
  searchCount++;
  console.log(`検索 ${searchCount}回目 minAge=${query.minAge}`);
  return new Promise((resolve) =>
    setTimeout(() => resolve(CATS.filter((c) => c.age >= query.minAge).map((c) => c.name)), 20),
  );
}

この後のコンポーネントは、親から minAge(最初は3)を受け取ります。親には「5歳以上にする」ボタンがあり、押すと minAge が5になります。各コードの先頭のコメントにある番号は、この記事で紹介する順に付けています。

依存配列を書き忘れると、検索が止まらない

まず、依存配列そのものを書き忘れた場合です。

tsx
// 1. 依存配列を書き忘れる
export function CatsNoDeps({ minAge }: { minAge: number }) {
  const [cats, setCats] = useState<string[]>([]);
  useEffect(() => {
    searchCats({ minAge }).then(setCats);
  });
  return <CatList cats={cats} />;
}

CatList は、名前を「 / 」でつないで表示するだけのコンポーネントです。ページを開いて1秒待ち、検索の回数を数えました。

text
--- CatsNoDeps: 開いてから1秒 ---
  検索の回数: 50
  150ms 検索 1回目 minAge=3
  172ms 検索 2回目 minAge=3
  193ms 検索 3回目 minAge=3
  …(中略)
  1172ms 検索 50回目 minAge=3
  表示: たま / みけ / しろ
  console.error: 0件
  2秒の時点の検索の回数: 98

minAge は3のままなのに、検索は約20ミリ秒ごとに呼ばれ続け、2秒の時点で98回になりました。表示は正しいので、画面を見ただけでは気づきにくい間違いです。React の警告(console.error)も出ませんでした。

useEffect のリファレンスにあるとおり、依存配列を省くと、エフェクトはコンポーネントのコミットのたびに動きます。ここでは、検索の結果で setCats を呼ぶと、新しい配列が state に入って再描画が起き、その後にまたエフェクトが動いて検索する、という流れが止まりません。

描画のたびに作るオブジェクトを入れても、同じことが起きる

依存配列を書いていても、そこに描画のたびに新しく作るオブジェクトを入れると、同じループになります。

tsx
// 2. 描画のたびに作るオブジェクトを、依存配列に入れる
export function CatsObjectDep({ minAge }: { minAge: number }) {
  const [cats, setCats] = useState<string[]>([]);
  const query = { minAge };
  useEffect(() => {
    searchCats(query).then(setCats);
  }, [query]);
  return <CatList cats={cats} />;
}
text
--- CatsObjectDep: 開いてから1秒 ---
  検索の回数: 50
  138ms 検索 1回目 minAge=3
  161ms 検索 2回目 minAge=3
  182ms 検索 3回目 minAge=3
  …(中略)
  1157ms 検索 50回目 minAge=3
  表示: たま / みけ / しろ
  console.error: 0件
  2秒の時点の検索の回数: 98

React は、依存配列の値を前回の値と Object.is で比べます(useEffect のリファレンス)。query は描画のたびに作り直される別のオブジェクトなので、中身が { minAge: 3 } のままでも、毎回「変わった」と判断されます。公式ドキュメントの依存配列を減らす方法の解説でも、中身が同じでも、新しく作ったオブジェクトや関数は、それぞれ別のものとみなされる、と説明されています。

オブジェクトはエフェクトの中で作り、依存配列には数値を入れる

直すには、オブジェクトをエフェクトの中で作り、依存配列には数値の minAge を入れます。useEffect のリファレンスにある、オブジェクトの依存を減らす方法と同じ形です。

tsx
// 3. オブジェクトはエフェクトの中で作り、依存配列には数値の minAge を入れる
export function CatsFixed({ minAge }: { minAge: number }) {
  const [cats, setCats] = useState<string[]>([]);
  useEffect(() => {
    const query = { minAge };
    searchCats(query).then(setCats);
  }, [minAge]);
  return <CatList cats={cats} />;
}
text
--- CatsFixed: 開いてから1秒 ---
  検索の回数: 1
  174ms 検索 1回目 minAge=3
  表示: たま / みけ / しろ
  console.error: 0件
  2秒の時点の検索の回数: 1

検索は1回だけになりました。数値は、値が同じなら Object.is で同じと判断されるので、minAge が変わらないかぎり、エフェクトはもう動きません。

このコードは、依存配列の話に絞るため、応答の届く順番が入れ替わる問題(競合)への対策を省いています。実際にデータを取るときは、useEffect のリファレンスにある、エフェクトでデータを取る例のように、片付け関数で古い応答を捨てる処理も入れてください。

値を入れ忘れると、古い値のまま動く

今度は逆に、エフェクトの中で使う値を依存配列に入れ忘れた場合です。「最初に1回だけ検索すればよい」と考えて、空の配列を書いたとします。

tsx
// 4. 依存配列に minAge を入れ忘れる
export function CatsMissingDep({ minAge }: { minAge: number }) {
  const [cats, setCats] = useState<string[]>([]);
  useEffect(() => {
    searchCats({ minAge }).then(setCats);
  }, []);
  return <CatList cats={cats} />;
}

ページを開いて300ミリ秒後に、親の「5歳以上にする」ボタンを押しました。比べるために、3の CatsFixed でも同じ操作をしています。

text
--- CatsMissingDep: 開いて、300ミリ秒後に「5歳以上にする」を押す ---
  押す前: minAge: 3 / 表示: たま / みけ / しろ
  押した後: minAge: 5 / 表示: たま / みけ / しろ
  135ms 検索 1回目 minAge=3
  console.error: 0件
--- CatsFixed: 開いて、300ミリ秒後に「5歳以上にする」を押す ---
  押す前: minAge: 3 / 表示: たま / みけ / しろ
  押した後: minAge: 5 / 表示: みけ / しろ
  149ms 検索 1回目 minAge=3
  496ms 検索 2回目 minAge=5
  console.error: 0件

CatsMissingDep は、minAge が5になっても検索し直さず、3歳の「たま」が残ったままでした。CatsFixed は minAge=5 で検索し直し、「みけ / しろ」に変わっています。

タイマーの中で count + 1 を渡すと、1で止まる

古い値の間違いは、タイマーでも起きます。1秒ごとに数を増やすつもりで、空の依存配列のエフェクトから setCount(count + 1) を呼びます。

tsx
// 5. 空の依存配列のタイマーで、count + 1 を渡す
export function TimerStale() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <p className="count">{count}</p>;
}

タイマーの関数は、最初の描画のときの count(0)を見続けます。そのため、何回呼ばれても setCount(0 + 1) になります。直すには、useEffect のリファレンスにあるとおり、count を読まずに、更新用の関数 c => c + 1 を渡します。

tsx
// 6. 更新用の関数 c => c + 1 を渡す
export function TimerUpdater() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <p className="count">{count}</p>;
}
text
--- TimerStale: 開いて0.5秒後から、1秒ごとに表示を読む ---
  0.5秒: 0 / 1.5秒: 1 / 2.5秒: 1 / 3.5秒: 1 / 4.5秒: 1
  console.error: 0件
--- TimerUpdater: 開いて0.5秒後から、1秒ごとに表示を読む ---
  0.5秒: 0 / 1.5秒: 1 / 2.5秒: 2 / 3.5秒: 3 / 4.5秒: 4
  console.error: 0件

次の GIF は、2つを同時に開き、最初にタイマーの関数が呼ばれる前から、6回目が呼ばれた後までを撮ったもの(約7秒)です。画面の一番上の「経過 N秒」は、上のパネルのタイマーの関数が呼ばれた回数に合わせて進めています。右側の「タイマー N回」は、タイマーの関数が呼ばれた回数です。上はタイマーが6回呼ばれても表示は1のまま、下は呼ばれるたびに1ずつ増えています。

上の setCount(count + 1) はタイマーが6回呼ばれても表示が1のまま。下の setCount(c =&gt; c + 1) は1から6まで増える
2つのタイマーを同時に動かした約7秒間。表示の数字とタイマーの回数が変わった瞬間をピンクで示す

更新用の関数を渡すと、エフェクトの中で count を読まなくなるので、依存配列に count を入れる必要もなくなります。依存配列を減らす方法の解説にある、前の state から次の state を計算する場合の節でも、同じ直し方が紹介されています。

ESLint の警告で、どの間違いも見つかる

ここまでのコンポーネントを1つのファイルに入れ、eslint-plugin-react-hooks の recommended の設定で ESLint にかけました。7は、次の節で扱うコンポーネントです。

js
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  {
    files: ['src/**/*.tsx'],
    languageOptions: { parser: tseslint.parser },
  },
  reactHooks.configs.flat.recommended,
]);

結果を、行:列、警告かエラーか、ルール名、メッセージの1行目だけにして出すと、次のとおりです。

text
11:3 warning react-hooks/exhaustive-deps
  React Hook useEffect contains a call to 'setCats'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [minAge] as a second argument to the useEffect Hook.
20:9 warning react-hooks/exhaustive-deps
  The 'query' object makes the dependencies of useEffect Hook (at line 23) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'query' in its own useMemo() Hook.
42:6 warning react-hooks/exhaustive-deps
  React Hook useEffect has a missing dependency: 'minAge'. Either include it or remove the dependency array.
54:6 warning react-hooks/exhaustive-deps
  React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array. You can also do a functional update 'setCount(c => ...)' if you only need 'count' in the 'setCount' call.
73:3 warning react-hooks/exhaustive-deps
  React Hook useEffect contains a call to 'setRenders'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [renders] as a second argument to the useEffect Hook.
74:5 error react-hooks/set-state-in-effect
  Error: Calling setState synchronously within an effect can trigger cascading renders
行 コンポーネント 警告の内容
11 1 CatsNoDeps 依存配列がないので、更新が無限に続くおそれがある。[minAge] を渡す
20 2 CatsObjectDep 描画のたびに作り直す query のせいで、依存配列が毎回変わる。エフェクトの中に移すか、useMemo で包む
42 4 CatsMissingDep 依存配列に minAge がない
54 5 TimerStale 依存配列に count がない。setCount(c => ...) でもよい
73、74 7 RenderCounter 依存配列がない。エフェクトの中ですぐに state を更新している

直した3と6には、警告は出ませんでした。5の警告は、count を依存配列に入れる、依存配列をなくす、更新用の関数を使う、の3つを示しています。useEffect のリファレンスでは、count を依存配列に入れると、count が変わるたびにタイマーを作り直すことになり望ましくない、として更新用の関数を勧めています。

注意

警告を消すために // eslint-disable-next-line react-hooks/exhaustive-deps を書くと、4や5の間違いが残ったまま見えなくなります。公式ドキュメントの依存配列の lint を黙らせるのが危ない理由の説明では、この lint のエラーをコンパイルエラーと同じように扱うことを勧めています。

この検証で React が警告を出したのは、すぐに state を更新したときだけ

1と2の無限ループでは、React の警告は出ませんでした。比べるために、依存配列なしのエフェクトで、await などを挟まずにすぐ state を更新するコンポーネントも動かしました。

tsx
// 7. 依存配列なしで、エフェクトの中ですぐに state を更新する
export function RenderCounter() {
  const [renders, setRenders] = useState(0);
  useEffect(() => {
    setRenders(renders + 1);
  });
  return <p className="count">{renders}</p>;
}
text
--- RenderCounter: 開いてから1秒 ---
  表示: 17621
  console.error: 338件 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.
  2秒の時点の表示: 31928
  2秒の時点の console.error: 613件

こちらは「Maximum update depth exceeded」という console.error が出ました。ただし、エラーで止まるわけではなく、2秒の時点でも表示は増え続けていました。

ポイント

無限ループでも、React が警告を出すとは限りません。この検証では、検索の結果を待ってから state を更新する1と2では、1秒に約50回の検索が続いても、console.error は0件でした。依存配列の間違いは、画面や console を見るより、ESLint の警告で見つける方が確実です。

間違いと直し方の一覧

間違い 起きたこと 直し方
依存配列を書き忘れる 検索が約20ミリ秒ごとに続く 使う値を依存配列に書く
描画のたびに作るオブジェクトを入れる 同じく検索が続く オブジェクトをエフェクトの中で作り、数値などを依存配列に入れる
使う値を入れ忘れる minAge が変わっても検索し直さない 使う値を依存配列に入れる
タイマーで count + 1 を渡す 表示が1で止まる 更新用の関数 c => c + 1 を渡す

依存配列は、エフェクトをいつ動かすかを自分で選ぶ場所ではなく、エフェクトの中で使っている値を書く場所です。依存配列を減らす方法の解説にある、依存を変えたいときはコードを変える、という節でも、依存配列に何を入れるかは選ぶものではなく、コードを表すものだと説明されています。依存配列を変えたいときは、先にコードの方を変えます。

​Posted by 86