これはHappiness Chain Advent Calendar 2024の7日目の記事です。
はじめに
Reactでstateが変わっていないのに無限ループになる挙動を見つけ疑問に思ったので、調査して自分なりに理解したことをまとめます。
公式ドキュメントの『コンポーネント間で state を共有する – React』を読んでサンプルコードをいじっていました。
上下に2つのパネルがあり、Showボタンを押すことで押した方のパネルが開き、もう片方のパネルは閉じるというコードです。useStateで初期値を0にセットしており、最初は上のパネルが開いている状態になります。
import { useState } from 'react';
export default function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<h2>Almaty, Kazakhstan</h2>
<Panel
title="About"
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
</Panel>
<Panel
title="Etymology"
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
The name comes from <span lang="kk-KZ">алма</span>, the Kazakh word for "apple" and is often translated as "full of apples". In fact, the region surrounding Almaty is thought to be the ancestral home of the apple, and the wild <i lang="la">Malus sieversii</i> is considered a likely candidate for the ancestor of the modern domestic apple.
</Panel>
</>
);
}
function Panel({
title,
children,
isActive,
onShow
}) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={onShow}>
Show
</button>
)}
</section>
);
}
このコードの中にonShow={() => setActiveIndex(0)}
という箇所がありますが、これをonShow={setActiveIndex(0)}
とすると、画像の通り無限レンダリングとなりました。
<Panel
title="About"
isActive={activeIndex === 0}
- onShow={() => setActiveIndex(0)}
+ onShow={setActiveIndex(0)}
>
前提として、この書き方自体は良くないです。なぜならonShow
のイベントハンドラに直接set関数を渡しているからです。アロー関数も同様ですが、イベントハンドラには関数を呼び出すのではなく渡すべきというReactの決まりがあり、onShow={setActiveIndex(0)}
とするとset関数を直接呼び出していることになります。そのため、アロー関数の形で書いてset関数を渡す必要があります。
それを差し置いても気になったのが、 stateの初期値は0であるため、onShow={setActiveIndex(0)}
としても書き方的にはNGだがstateは0のまま、つまりstateが更新されていないため無限ループは起きないのでは? ということです。
これがたとえばonShow={setActiveIndex(activeIndex + 1)}
のようになっていれば、値が次々+1されていくので無限ループになるのも理解できます。
初期値の0とset関数でセットした0は違うのか?
もしかして 初期値の0とset関数でセットした0はプログラム上では別のものとして扱われている? と思い調べたところ、公式ドキュメントで下記のような記述がありました。
新しい値が現在の state と同一の場合、React は最適化のために、コンポーネントとその子コンポーネントの再レンダーをスキップします。state の同一性の比較は、Object.is によって行われます。
で、このObject.isというのは 2 つの値が同一値であるかどうかを判定するメソッドのようです。
console.log(Object.is('1', 1));
// Expected output: false
console.log(Object.is(NaN, NaN));
// Expected output: true
console.log(Object.is(-0, 0));
// Expected output: false
const obj = {};
console.log(Object.is(obj, {}));
// Expected output: false
上3つはいいとして、一番下は要注意です。オブジェクトの場合アドレスを比較するらしく、中身自体は同じでも結果はfalseになります。
JavaScriptでは数値はプリミティブ型であり、単純に値を比較するためtrueになります。
下記の記事も参考にさせていただきました。
とすると、 初期値の0とset関数でセットした0はプログラム上では別のものとして扱われている という可能性は低く、別の原因で無限ループが起きていると考えられます。
公式ドキュメントの説明では
先程の公式ドキュメントを読んでいたところ下記のような記載がありました。
レンダー中に set 関数を呼び出すことは、現在レンダー中のコンポーネント内からのみ許されています。その場合、React はその出力を破棄し、新しい state で再レンダーを試みます。このパターンが必要になることはほとんどありませんが、前回のレンダーからの情報を保存するために使用できます。
Too many re-renders. React limits the number of renders to prevent an infinite loop. というエラーが出ることがあります。これは通常、レンダー中に無条件に set 関数を呼び出しているため、コンポーネントがループに入っていることを意味します。レンダー、set 関数の呼び出し(レンダーを引き起こす)、レンダー、set 関数の呼び出し(レンダーを引き起こす)、というように続きます。大抵の場合、これはイベントハンドラの指定を間違ったことによるものです。
// 🚩 Wrong: calls the handler during render return <button onClick={handleClick()}>Click me</button> // ✅ Correct: passes down the event handler return <button onClick={handleClick}>Click me</button> // ✅ Correct: passes down an inline function return <button onClick={(e) => handleClick(e)}>Click me</button>
上の方の記載は、onShow={setActiveIndex(0)}
することで初回のレンダリングの時点で即set関数が実行され、再レンダーを試みる => set関数が即実行され再レンダーを試みる => set関数が即実行され再レンダーを試みる...
という無限ループになるということでしょうか。よくわかりません。
下の方の記載についても、stateが変わっていなくてもset関数を呼び出すとレンダリングが起こるというのがいまいちわかりません。
Reactでのレンダリングとは
そこでReactが指すレンダリングについて調べたところ、『レンダーとコミット – React』に下記の記載がありました。そもそも公式ドキュメントでは「レンダリング」ではなく「レンダー」という言い方をしています。
「レンダー」とは、React がコンポーネントを呼び出すことです。
レンダーが完了し、React が DOM を更新した後、ブラウザは画面を再描画します。このプロセスは「ブラウザレンダリング」として知られていますが、我々は、混乱を避けるために、ドキュメント全体を通して「ペイント」と呼ぶことにします。
そして、レンダーが起こる条件として
コンポーネントがレンダーされる理由には 2 つあります。
- コンポーネントの 初回レンダー
- コンポーネント(またはその祖先のいずれか)の state の更新
とあります。
今までは「 画面が新しい状態になること = 再レンダリング 」と思っていました。ですが、Reactではコンポーネントを呼び出す「レンダー」という処理のあとに、画面を再描画する「ペイント」という処理があるようです。
公式ドキュメントにあった「set 関数の呼び出し(レンダーを引き起こす)
」という説明が理解できました。どんな値にセットしようがset関数を実行した時点でレンダーまでは行われており、値が変わっていればペイントが行われるということでしょう。
まとめ
以上のことから、初期値が0でonShow={setActiveIndex(0)}
と書くと無限ループが起こるプロセスは
- イベントハンドラで直接set関数を呼び出している
- set関数の呼び出し自体が、元のstateの値と同じだろうがレンダーを引き起こす
- コンポーネントが呼び出され(再描画ではない)、
onShow={setActiveIndex(0)}
が実行されるのでレンダーを引き起こす - 上記が無限ループする
と理解しました。
v0に聞くと
v0に聞いてみました。
質問
(コード)
activeIndexが0の場合に、onShow={() => setActiveIndex(0)}をonShow={setActiveIndex(0)}とすると無限ループになるのはなぜですか?stateが0から0になってるので更新されていない、つまりレンダリングは起きないと思うのですが
回答
ご質問ありがとうございます。
onShow={setActiveIndex(0)}
とすると無限ループが発生する理由を説明いたします。
- 関数の即時実行:
onShow={setActiveIndex(0)}
と書くと、この式はsetActiveIndex(0)
を即座に実行し、その戻り値をonShow
プロパティに割り当てようとします。つまり、コンポーネントがレンダリングされるたびにsetActiveIndex(0)
が呼び出されてしまいます .- 状態の更新とレンダリング:
setActiveIndex(0)
が呼び出されると、activeIndex
の値が現在 0 であっても、React は新しい状態で再レンダリングを行います . これは、React が状態の変更を検知したためです。- 無限ループの発生:
コンポーネントが再レンダリングされるたびに、再びsetActiveIndex(0)
が呼び出され、さらなる再レンダリングが発生します。これが繰り返されることで無限ループとなります。
正しい書き方はonShow={() => setActiveIndex(0)}
です。これはクリックイベントが発生したときにのみsetActiveIndex(0)
を呼び出す関数を作成します。
activeIndex
が 0 から 0 に変わっても再レンダリングが発生する理由は、React が単純に新旧の値を比較するのではなく、状態更新関数が呼び出されたこと自体をトリガーとしてレンダリングを行うためです .
この挙動は、パフォーマンスの最適化よりも一貫性のある動作を優先する React の設計思想によるものです。ご質問の内容は React の重要な概念に触れていますね。理解を深める良い機会だと思います。
このような回答になったので、おおよそ先程の理解で合っていると思いますが、間違えていればご教示ください。
最後に
Reactは奥が深く難しいですね。ここまで調べなくても「 イベントハンドラで直接set関数を呼び出すとstateが変わっていなくても無限ループになる 」ととりあえず理解しておくだけでも十分だと思います。
【React】setCount(count + 1) と setCount((prevCount) => prevCount + 1) の違いでも書きましたが、自分はとりあえずの理解だと腑に落ちない性格なので詳しく調べてみました。まだ仕事でReactを使って間もないので、どんどん手を動かして理解を深めていきたいです。