【React】無限re-renderが起きる原因と対処法

JavaScript

こんにちは、かつコーチです。

前回は「keyがありません」警告を取り上げました。

今回は、React学習者の多くが一度は通る「画面がフリーズする」「コンソールが真っ赤になる」という無限re-renderを扱います。

Maximum update depth exceededというエラーメッセージを見たことがある人も多いはずです。

原因のパターンはある程度決まっているので、この記事で典型例と対処法をまとめて押さえておきましょう。

無限re-renderとは何が起きているのか

コンポーネントが再描画を止められなくなる状態

無限re-renderとは、状態の更新→再レンダリング→また状態の更新、というサイクルが止まらなくなる状態です。

Reactは、あまりに多くの更新が連続すると安全装置として処理を止め、次のエラーを表示します。

Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

もしくはuseEffectが絡んでいる場合は、次のようなエラーになることもあります。

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.

どちらも「更新のループが止まらない」ことを教えてくれるエラーで、原因のパターンはだいたい共通しています。

主な原因は3パターン

無限re-renderの原因は、経験上ほぼ次の3つに絞られます。

  • レンダリング中に直接setStateを呼んでいる
  • useEffectの依存配列を指定し忘れている
  • useEffectの依存配列に「毎回新しく作られる値」を入れてしまっている

順番に見ていきましょう。

パターン1:レンダリング中に直接setStateを呼んでいる

コンポーネント本体でsetStateを実行してしまう

一番わかりやすい原因が、コンポーネントの本体(レンダリングされる部分)で直接setStateを呼んでしまうケースです。

❌ Before:レンダリング中にsetStateを呼んでしまう

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  // レンダリングのたびに実行され、そのたびに再レンダリングが走る
  setCount(count + 1);

  return <p>カウント: {count}</p>;
}

このコードは、レンダリングされるたびにsetCountが呼ばれ、それがまた新しいレンダリングを引き起こします。

条件分岐もなく無条件で呼んでいるため、一瞬でエラーになります。

✅ After:イベントハンドラの中で呼ぶ

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

setStateは「何かのきっかけ(ボタンクリックやAPIレスポンスなど)」が起きたときに呼ぶものです。

コンポーネント本体に直接書くのではなく、イベントハンドラやuseEffectの中に移すのが基本です。

パターン2:useEffectの依存配列を指定し忘れている

依存配列なしでsetStateを呼ぶとどうなるか

useEffectは、第2引数の依存配列を省略すると「レンダリングのたびに毎回実行」されます。

その中で無条件にsetStateを呼んでしまうと、無限ループになります。

import { useEffect, useState } from "react";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<{ name: string } | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }); // 依存配列がない!

  return <p>{user?.name ?? "読み込み中..."}</p>;
}

setUserが呼ばれる→再レンダリングされる→依存配列がないのでuseEffectがまた実行される→またsetUserが呼ばれる、というループに陥ります。

依存配列に[userId]を指定すれば、userIdが変わったときだけ実行されるようになり、このループは止まります。

パターン3:依存配列に毎回新しく作られる値を入れてしまう

オブジェクト・配列のリテラルを依存配列に含める

これが実務でもっともハマりやすいパターンです。

import { useEffect, useState } from "react";

function SearchResult({ keyword }: { keyword: string }) {
  const [results, setResults] = useState<string[]>([]);

  const options = { keyword, limit: 20 }; // レンダリングのたびに新しいオブジェクト

  useEffect(() => {
    fetch("/api/search", {
      method: "POST",
      body: JSON.stringify(options),
    })
      .then((res) => res.json())
      .then((data) => setResults(data));
  }, [options]); // optionsは毎回「新しい」オブジェクトとして扱われる

  return <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>;
}

optionsはコンポーネント本体で毎回新しく作られるオブジェクトです。

JavaScriptでは、中身が同じでもオブジェクトの参照が違えば「別物」として扱われます。

そのためuseEffectの依存配列としては「毎回変化している」と判定され、実行→setResults→再レンダリング→また新しいoptionsが作られる→実行……というループになります。

つまずきやすいポイント:かつコーチが実際にハマった無限ループ

依存配列にオブジェクトを入れてしまい半日溶かした話

私が実際につまずいたのは、検索フォームの絞り込み条件をオブジェクトとしてまとめてuseEffectの依存配列に渡していたときです。

画面が固まり、開発者ツールを開くと大量のMaximum update depth exceededが流れ続けていました。

原因は上記のパターン3そのもので、絞り込み条件のオブジェクトを毎回コンポーネント本体で組み立てていたことでした。

❌ Before:オブジェクトをそのまま依存配列に入れる

function ProductFilter({ category, minPrice }: { category: string; minPrice: number }) {
  const [products, setProducts] = useState<string[]>([]);
  const filter = { category, minPrice };

  useEffect(() => {
    fetchProducts(filter).then(setProducts);
  }, [filter]);

  return <ProductList items={products} />;
}

✅ After:プリミティブな値を依存配列に指定する

function ProductFilter({ category, minPrice }: { category: string; minPrice: number }) {
  const [products, setProducts] = useState<string[]>([]);

  useEffect(() => {
    const filter = { category, minPrice };
    fetchProducts(filter).then(setProducts);
  }, [category, minPrice]);

  return <ProductList items={products} />;
}

オブジェクトの生成自体をuseEffectの中に移し、依存配列にはcategoryminPriceのようなプリミティブな値(文字列・数値・真偽値など)だけを指定するようにしました。

プリミティブな値は中身が同じであれば同じものとして比較されるため、categoryminPriceが変化していない限りuseEffectは再実行されません。

これ以来、私は「依存配列にオブジェクトや配列、関数をそのまま入れそうになったら、まず中身をプリミティブに分解できないか考える」というのを習慣にしています。

どうしてもオブジェクトを依存配列に入れたい場合

計算コストが高いオブジェクトを毎回作りたくない場合は、useMemoで値の中身が変わったときだけ新しいオブジェクトを作るようにする方法もあります。

import { useEffect, useMemo, useState } from "react";

function ProductFilter({ category, minPrice }: { category: string; minPrice: number }) {
  const [products, setProducts] = useState<string[]>([]);
  const filter = useMemo(() => ({ category, minPrice }), [category, minPrice]);

  useEffect(() => {
    fetchProducts(filter).then(setProducts);
  }, [filter]);

  return <ProductList items={products} />;
}

useMemoの依存配列であるcategoryminPriceが変わらない限り、filterは同じ参照を保ち続けます。

とはいえ、シンプルに済むなら依存配列をプリミティブな値だけにする方法を優先するのがおすすめです。

まとめ

この記事のポイント

  • 無限re-renderは「レンダリング中の直接的なsetState」「useEffectの依存配列なし」「依存配列に毎回新しい値」の3パターンが主な原因
  • setStateはイベントハンドラやuseEffectの中で呼ぶのが基本
  • useEffectには依存配列を必ず指定し、対象を絞り込む
  • オブジェクトや配列を依存配列に入れるときは、プリミティブな値に分解するかuseMemoで参照を安定させる

次に読むべき記事

次回は「React DevToolsでコンポーネントの状態を確認する方法」です。

無限re-renderのようなバグを見つけるときにも役立つツールなので、ぜひ続けて読んでみてください。

→ 次の記事:React DevToolsでコンポーネントの状態を確認する方法

タイトルとURLをコピーしました