ページをリフレッシュしても画面が真っ白なままだったり、ボタンをクリックした瞬間にCPUファンがフル回転し始めたりすることがあります。そして、コンソールには恐ろしい警告が表示されます:Maximum update depth exceeded. Reactが急ブレーキをかけたのです。あなたのコンポーネントが無限ループに陥っているからです。これはReactアプリケーションにおいて最も一般的なエラーの一つであり、通常、コードが実際に実行されるタイミングに関する単純な誤解から生じます。
これを解決するには、レンダリングがいつ再レンダリング(re-render)に変わるのか、そしてなぜstateの変更をレンダリングパスから外さなければならないのかを正確に理解する必要があります。
Reactによるコンポーネントのレンダリング方法
Reactはコンポーネントを使ってユーザーインターフェースを構築します。モダンなReactでは、これらのコンポーネントは関数です。Reactが画面にコンポーネントを表示する必要があるたびに、単にその関数を呼び出します。関数内では、stateを使用してレンダリング間で情報を保持できます。stateは、どのデータがコンポーネントに属しているのか、そして重要な点として、何かが変更されUIを更新する必要があるかどうかをReactに伝えます。
stateが変更されると、Reactは新しいレンダリングをスケジュールします。コンポーネント関数が再度実行されて新しいJSXを返し、Reactはそれに合わせてDOMを更新します。通常の利用において、このサイクルは無害です。ボタンをクリックすると、イベントハンドラーがstateを更新し、Reactが一度再レンダリングを行い、ユーザーには新しいテキストや色が反映されます。
このエラーは、レンダリング自体が別のstateの更新をトリガーしたときに発生します。その新しいstateの更新がまた別のレンダリングをトリガーし、それがさらに別のstateの更新をトリガーします。Reactは数十サイクルまではこれを許容しますが、ブラウザが完全にフリーズするのを防ぐために、最大深度エラー(maximum depth error)を投げます。
Stateの概要
ループを詳しく分析する前に、useStateフックがどのように機能するかを思い出してください。これは、現在の値を保持する変数と、その値を変更するための関数の、ちょうど2つのものを提供します。
const MessageComponent = () => {
const [message, setMessage] = useState('Welcome');
return <h1>{message}</h1>;
};
ここでは、最初のレンダリング時にmessageは'Welcome'です。その後でsetMessage('Goodbye')を呼び出すと、Reactは変更を検知してMessageComponentを再度呼び出し、UIには"Goodbye"が表示されます。コンポーネントの本体内でセッター(setter)が自動的に呼び出されていないため、すべては正常です。ループは、外部のイベントなしに、レンダリングフェーズ中にセッターが実行されたときに始まります。
コンポーネント本体で直接SetStateを呼び出す
無限ループを作成する最も直接的な方法は、コンポーネントの本体内で直接stateのセッター関数を呼び出すことです。コンポーネントの本体はレンダリングのたびに実行されるため、セッターもレンダリングのたびに実行されます。その新しいstateの更新がまた別のレンダリングを引き起こし、サイクルが永遠に回り続けます。
間違ったコードの例は以下の通りです:
const Counter = () => {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
};
Counterがレンダリングされるたびに、countが増加します。Reactは新しい数値を表示するために再度レンダリングを行い、再びsetCount(count + 1)を見つけて、さらにもう一度カウントアップします。解決策は単純です。レンダリング中にコンポーネントのトップレベルでstateのセッターを絶対に呼び出さないでください。stateの更新は、画面を描画するという行為に対してではなく、ユーザーイベントやサイドエフェクト(side effects)に反応すべきです。その更新をイベントハンドラーに移動させましょう:
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
レンダリング中にセッターを呼び出す唯一の例外は、propsから新しいstateを計算する場合ですが、その場合でも、値を直接導出(derive)したり、意図的にuseEffectを使用したりするなど、別のパターンを使用すべきです。
参照の代わりに関数呼び出しを渡してしまう
もう一つのよくある原因は、JSXの微妙なタイポです。イベントハンドラーをアタッチするときは、関数そのものを渡す必要があります。もしJSX内で誤って関数を呼び出してしまうと、レンダリングサイクル中に即座に実行されてしまいます。
const Toggle = () => {
const [isOn, setIsOn] = useState(false);
const handleToggle = () => setIsOn(!isOn);
return <button onClick={handleToggle()}>Toggle</button>;
};
handleToggle()のように括弧を付けて書くと、ユーザーがクリックしたときに後で呼び出すための関数をReactに渡していることにはなりません。Reactが仮想DOMを構築している「今まさに」その関数を呼び出していることになります。handleToggleはstateを更新するため、コンポーネントは再レンダリングされます。その再レンダリング中に、Reactは再びhandleToggle()を見つけ、それをまた呼び出します。ループは終わりません。正しいバージョンでは、括弧を取り除きます:
return <button onClick={handleToggle}>Toggle</button>;
引数を渡す必要がある場合は、呼び出しを無名関数でラップします:
return <button onClick={() => handleToggle(true)}>Switch On</button>;
この違いは、リファクタリングの際に経験豊富な開発者でさえも躓かせるものです。括弧の使い方には注意しましょう。
useEffectの依存関係の罠
Effectは、データの取得、ブラウザAPIとの同期、あるいはDOMの手動操作といったサイドエフェクトを行うのに適した場所です。しかし、useEffectはReactがレンダリングを画面にコミットした後に実行されます。もしEffect内でstateを更新すると、Reactは再レンダリングを行います。通常、これは問題ありません。問題となるのは、Effectがすべてのレンダリング後に実行され、常に同じstateを更新する場合、ループに陥ります。
次の壊れたパターンを考えてみましょう:
const UserProfile = () => {
const [user, setUser] = useState({});
useEffect(() => {
setUser({ name: 'Ada', role: 'Admin' });
});
return <div>{user.name}</div>;
};
依存関係配列がないため、このエフェクトはレンダリングのたびに実行されます。user をセットすることで、それが再びレンダリングをトリガーします。そのレンダリングの後、エフェクトが再び実行され、また user をセットします。React はこの連鎖を検知し、エラーをスローします。
修正方法は、適切な依存関係配列を提供することで、エフェクトが実際にいつ実行される必要があるのかを React に伝えることです。もしエフェクトをマウント時に一度だけ実行したい場合は、空の配列を渡します:
useEffect(() => {
setUser({ name: 'Ada', role: 'Admin' });
}, []);
エフェクトが prop や state に依存している場合は、その変数だけを配列に含めます。ただし、注意が必要です。レンダリングのたびに変化する変数を含めると、別の経路を通って同じループを再生成するだけになってしまいます。例えば、依存関係にオブジェクトリテラルを含めており、そのオブジェクトが親コンポーネントのレンダリングのたびに再生成される場合、エフェクトは無限に実行されます。そのような場合は、オブジェクトの作成をコンポーネントの外に移動するか、メモ化(memoize)する必要があります。
実践的なデバッグ手順
このエラーが発生した際、React はすでに数十回もサイクルを繰り返しているため、スタックトレースが膨大に見えることがあります。まずはトレースの冒頭を読み、どのコンポーネントが繰り返し名前として現れているかを確認してください。次に、以下の3つの場所で state setter を探します:
- ハンドラーやフックの外にある、コンポーネントのメインボディ。
handlerと書くべきところをhandler()と書いてしまっている可能性がある JSX のイベント属性。- 依存関係配列が欠落している、あるいは不安定な参照に依存している
useEffectフック。
エラーが止まるまで、各 state setter を一時的にコメントアウトしてみてください。そうすることで、どの更新が原因であるかが正確にわかります。もし setter がエフェクト内にある場合は、そこに本当に state が必要かどうかを自問してみてください。開発者が、JSX 内で直接 prop を使えるにもかかわらず、エフェクト内で prop からローカル state を設定してしまうことが時々あります。
真の教訓
Maximum update depth error は、謎めいた React のバグではありません。それはセーフティネットです。つまり、コンポーネントが外部からの信号を待つ代わりに、自分自身を再レンダリングしようとしていることを意味します。「レンダリングを、さらなる state を生成すべきイベントとして扱う」習慣を捨てましょう。レンダリングを原因ではなく、state の純粋な結果として扱ってください。state の更新は、イベントハンドラー、コールバック、または慎重に選択された依存関係を持つエフェクトの中に留めておけば、このエラーに二度と遭遇することはないでしょう。
