【React】stateが変わっていないのに無限ループするのはなぜ？

これは[Happiness Chain Advent Calendar 2024](https://adventar.org/calendars/10341)の7日目の記事です。

# はじめに
Reactでstateが変わっていないのに無限ループになる挙動を見つけ疑問に思ったので、調査して自分なりに理解したことをまとめます。

公式ドキュメントの『[コンポーネント間で state を共有する – React](https://ja.react.dev/learn/sharing-state-between-components)』を読んでサンプルコードをいじっていました。

上下に2つのパネルがあり、Showボタンを押すことで押した方のパネルが開き、もう片方のパネルは閉じるというコードです。useStateで初期値を0にセットしており、最初は上のパネルが開いている状態になります。

```jsx
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>
  );
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2207701/b181be86-3a27-8cbb-4d4e-71f52a777fd8.png)

このコードの中に`onShow={() => setActiveIndex(0)}`という箇所がありますが、これを`onShow={setActiveIndex(0)}`とすると、画像の通り無限レンダリングとなりました。

```diff_jsx
<Panel
  title="About"
  isActive={activeIndex === 0}
- onShow={() => setActiveIndex(0)}
+ onShow={setActiveIndex(0)}
>
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2207701/cdc900f1-5eca-365e-8514-f0b812254906.png)

前提として、この書き方自体は良くないです。なぜなら`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 によって行われます。

https://ja.react.dev/reference/react/useState

で、このObject.isというのは 2 つの値が同一値であるかどうかを判定するメソッドのようです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/is

```js
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になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2207701/3aa04fb6-61af-86dd-6ae4-b9fc5ae5a076.png)

0と0をコンソールで比較した結果は以下のとおりです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2207701/c9800d8c-52c6-8be9-93cc-d78368666ba9.png)

JavaScriptでは数値はプリミティブ型であり、単純に値を比較するためtrueになります。

下記の記事も参考にさせていただきました。

https://zenn.dev/unkeleven/articles/eee1af3ba049ab

とすると、 __初期値の0とset関数でセットした0はプログラム上では別のものとして扱われている__ という可能性は低く、別の原因で無限ループが起きていると考えられます。

# 公式ドキュメントの説明では
先程の公式ドキュメントを読んでいたところ下記のような記載がありました。

> レンダー中に set 関数を呼び出すことは、現在レンダー中のコンポーネント内からのみ許されています。その場合、React はその出力を破棄し、新しい state で再レンダーを試みます。このパターンが必要になることはほとんどありませんが、前回のレンダーからの情報を保存するために使用できます。

> Too many re-renders. React limits the number of renders to prevent an infinite loop. というエラーが出ることがあります。これは通常、レンダー中に無条件に set 関数を呼び出しているため、コンポーネントがループに入っていることを意味します。レンダー、set 関数の呼び出し（レンダーを引き起こす）、レンダー、set 関数の呼び出し（レンダーを引き起こす）、というように続きます。大抵の場合、これはイベントハンドラの指定を間違ったことによるものです。
> ```js
>// 🚩 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](https://ja.react.dev/learn/render-and-commit)』に下記の記載がありました。そもそも公式ドキュメントでは「レンダリング」ではなく「レンダー」という言い方をしています。

> 「レンダー」とは、React がコンポーネントを呼び出すことです。

> レンダーが完了し、React が DOM を更新した後、ブラウザは画面を再描画します。このプロセスは「ブラウザレンダリング」として知られていますが、我々は、混乱を避けるために、ドキュメント全体を通して「ペイント」と呼ぶことにします。

そして、レンダーが起こる条件として

> コンポーネントがレンダーされる理由には 2 つあります。
> 1. コンポーネントの __初回レンダー__ 
> 2. コンポーネント（またはその祖先のいずれか）の __state の更新__

とあります。

今までは「 __画面が新しい状態になること = 再レンダリング__ 」と思っていました。ですが、Reactではコンポーネントを呼び出す「レンダー」という処理のあとに、画面を再描画する「ペイント」という処理があるようです。

公式ドキュメントにあった「`set 関数の呼び出し（レンダーを引き起こす）`」という説明が理解できました。どんな値にセットしようがset関数を実行した時点でレンダーまでは行われており、値が変わっていればペイントが行われるということでしょう。


# まとめ
以上のことから、初期値が0で`onShow={setActiveIndex(0)}`と書くと無限ループが起こるプロセスは

1. __イベントハンドラで直接set関数を呼び出している__
1. __set関数の呼び出し自体が、元のstateの値と同じだろうがレンダーを引き起こす__
1. __コンポーネントが呼び出され(再描画ではない)、`onShow={setActiveIndex(0)}`が実行されるのでレンダーを引き起こす__
1. __上記が無限ループする__ 

と理解しました。

# v0に聞くと
v0に聞いてみました。

> __質問__
>
>(コード) 
>activeIndexが0の場合に、onShow={() => setActiveIndex(0)}をonShow={setActiveIndex(0)}とすると無限ループになるのはなぜですか？stateが0から0になってるので更新されていない、つまりレンダリングは起きないと思うのですが

> __回答__
>
> ご質問ありがとうございます。`onShow={setActiveIndex(0)}` とすると無限ループが発生する理由を説明いたします。
> 1. 関数の即時実行:
`onShow={setActiveIndex(0)}` と書くと、この式は `setActiveIndex(0)` を即座に実行し、その戻り値を `onShow` プロパティに割り当てようとします。つまり、コンポーネントがレンダリングされるたびに `setActiveIndex(0)` が呼び出されてしまいます .
>2. 状態の更新とレンダリング:
`setActiveIndex(0)` が呼び出されると、`activeIndex` の値が現在 0 であっても、React は新しい状態で再レンダリングを行います . これは、React が状態の変更を検知したためです。
>3. 無限ループの発生:
コンポーネントが再レンダリングされるたびに、再び `setActiveIndex(0)` が呼び出され、さらなる再レンダリングが発生します。これが繰り返されることで無限ループとなります。
正しい書き方は `onShow={() => setActiveIndex(0)}` です。これはクリックイベントが発生したときにのみ `setActiveIndex(0)` を呼び出す関数を作成します。
`activeIndex` が 0 から 0 に変わっても再レンダリングが発生する理由は、React が単純に新旧の値を比較するのではなく、状態更新関数が呼び出されたこと自体をトリガーとしてレンダリングを行うためです .
この挙動は、パフォーマンスの最適化よりも一貫性のある動作を優先する React の設計思想によるものです。
>
> ご質問の内容は React の重要な概念に触れていますね。理解を深める良い機会だと思います。

このような回答になったので、おおよそ先程の理解で合っていると思いますが、間違えていればご教示ください。

# 最後に
Reactは奥が深く難しいですね。ここまで調べなくても「 __イベントハンドラで直接set関数を呼び出すとstateが変わっていなくても無限ループになる__ 」ととりあえず理解しておくだけでも十分だと思います。

[【React】setCount(count + 1) と setCount((prevCount) =&gt; prevCount + 1) の違い](https://qiita.com/null_candy/items/43822b959a4b50dc6205)でも書きましたが、自分はとりあえずの理解だと腑に落ちない性格なので詳しく調べてみました。まだ仕事でReactを使って間もないので、どんどん手を動かして理解を深めていきたいです。