Reactのstateが残るか消えるかは位置と種類で決まる

​​React​TypeScript,useState,条件付きレンダー

React で表示を切り替えると、前の入力やカウントが残ることも、消えることもあります。どちらになるかは、画面のツリーの同じ位置に、同じ種類のコンポーネントが描画され続けているかで決まります。種類とは、Counter のような関数、または div や p のような要素の名前のことです。

タブや「編集/表示」のモードを条件付きレンダーで出し分けたときに、入力した内容が消えた、または前の画面の値が残った、という場合の原因を探す手がかりになります。私は検証環境で、クリックした回数を state に持つカウンターを5通りの書き方で出し分け、切り替えたあとに回数が残るかを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、react / react-dom 19.3.1、TypeScript 7.0.2 です。.tsx を tsc で型チェックしてから esbuild で開発用にまとめ、Playwright 1.63.0 で動かした Chromium 153 で開いてクリックしました。マウントのやり直しが記録に混ざらないよう、StrictMode は使っていません。Vite や Next.js の上での動作、Chromium 以外のブラウザーでの動作は試していません。

検証に使ったカウンター

Counter は、押した回数を count という state に持つボタンです。エフェクトで、マウントとアンマウントを記録します。log は、記録を残すだけの検証用の関数です。

tsx
// Counter.tsx
import { useEffect, useState } from 'react';
import { log } from './log';

// クリックした回数を state に持つカウンター
export function Counter({ label }: { label: string }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    log(`マウント(${label})`);
    return () => log(`アンマウント(${label})`);
  }, []);

  return (
    <button type="button" className="counter" onClick={() => setCount(count + 1)}>
      {label}:{count}回
    </button>
  );
}

どの書き方でも、カウンターを3回押してから「切り替え」を押し、もう一度「切り替え」を押して元の表示に戻しました。切り替えの前に、カウンターのボタンの要素に data-mark 属性で目印を書き込んでおき、切り替えたあとも同じ DOM の要素が使われているかを調べています。

三項演算子で同じ Counter を出し分けると、回数が残る

1つ目は、三項演算子で label だけが違う Counter を出し分ける書き方です。

tsx
export function SameType() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? <Counter label="派手なカウンター" /> : <Counter label="普通のカウンター" />}
      <button type="button" className="toggle" onClick={() => setIsFancy(!isFancy)}>切り替え</button>
    </div>
  );
}
text
  [マウント(普通のカウンター)]
  > カウンターを3回クリック
  画面: 普通のカウンター:3回
  > 「切り替え」をクリック(1回目)
  画面: 派手なカウンター:3回
  ボタンの DOM は前と同じ要素か: true
  > 「切り替え」をクリック(2回目)
  画面: 普通のカウンター:3回

表示は「派手なカウンター」に変わりましたが、回数は3回のままでした。アンマウントもマウントも記録されておらず、ボタンの要素も前と同じです。JSX には <Counter> を2回書いていますが、React から見ると、div の1番目の子に Counter が描画され続けているだけで、label という props が変わったにすぎません。

公式ドキュメントの state の保持とリセットのページでも、同じ位置に同じコンポーネントがあれば state は保たれると説明されています。大事なのは JSX の書き方ではなく、描画した結果のツリーの中での位置です。同じ節の注意の囲みには、if の中と外で別々に return した <Counter /> も、同じ位置なので state が残る例があります。

同じ位置で種類が変わると、回数が消える

2つ目は、同じ位置に Counter と <p> を出し分ける書き方です。

tsx
export function DifferentType() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? <p>休憩中</p> : <Counter label="カウンター" />}
      <button type="button" className="toggle" onClick={() => setIsPaused(!isPaused)}>切り替え</button>
    </div>
  );
}
text
  [マウント(カウンター)]
  > カウンターを3回クリック
  画面: カウンター:3回
  > 「切り替え」をクリック(1回目)
  [アンマウント(カウンター)]
  画面: <p>休憩中</p>
  > 「切り替え」をクリック(2回目)
  [マウント(カウンター)]
  画面: カウンター:0回

<p> に切り替えた時点で Counter はアンマウントされ、戻したときは新しくマウントされて、0回から始まりました。同じ位置でも、種類が変わると React はそれまでのコンポーネントを state ごと取り除きます。

下の GIF は、1つ目と2つ目の書き方を上下に並べ、1つの「切り替え」ボタンで両方を同時に切り替えたデモです。

上下のカウンターを2回ずつ押してから「切り替え」を2回押すと、上の「同じ位置・同じ種類」は2回のまま残り、下の「同じ位置・種類が変わる」は p 要素を挟んで0回に戻る
カウンターを押してから表示を切り替え、元に戻すまでの動き。押したボタンと、変わった表示をその瞬間だけピンクにしている

上は表示が A から B に変わっても2回のままです。下は、いったん「休憩中(p 要素)」になり、戻すと0回になりました。

包む要素が変わると、中の Counter も作り直される

3つ目は、Counter 自体は同じで、それを包む要素だけが div と section で変わる書き方です。

tsx
export function DifferentWrapper() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <section><Counter label="sectionの中" /></section>
      ) : (
        <div><Counter label="divの中" /></div>
      )}
      <button type="button" className="toggle" onClick={() => setIsFancy(!isFancy)}>切り替え</button>
    </div>
  );
}
text
  [マウント(divの中)]
  > カウンターを3回クリック
  画面: divの中:3回
  > 「切り替え」をクリック(1回目)
  [アンマウント(divの中)]
  [マウント(sectionの中)]
  画面: sectionの中:0回
  ボタンの DOM は前と同じ要素か: false
  > 「切り替え」をクリック(2回目)
  [アンマウント(sectionの中)]
  [マウント(divの中)]
  画面: divの中:0回

Counter は同じ関数で、包む要素の中の位置も同じなのに、回数は0回に戻り、ボタンの要素も新しいものに替わりました。Counter を包む div が section に変わったことで、React はその下にあるものをまとめて作り直しています。

同じページの、別のコンポーネントを同じ位置に描画した場合の説明にあるとおり、種類が変わると、その下のツリー全体の state がリセットされます。state を残したいなら、描画のたびにツリーの形がそろっている必要がある、とも書かれています。

&& を2つ並べると、別の位置になる

4つ目は、三項演算子の代わりに && を2つ並べる書き方です。出し分けているのは1つ目と同じ Counter です。

tsx
export function TwoPositions() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy && <Counter label="派手なカウンター" />}
      {!isFancy && <Counter label="普通のカウンター" />}
      <button type="button" className="toggle" onClick={() => setIsFancy(!isFancy)}>切り替え</button>
    </div>
  );
}
text
  [マウント(普通のカウンター)]
  > カウンターを3回クリック
  画面: 普通のカウンター:3回
  > 「切り替え」をクリック(1回目)
  [アンマウント(普通のカウンター)]
  [マウント(派手なカウンター)]
  画面: 派手なカウンター:0回
  ボタンの DOM は前と同じ要素か: false
  > 「切り替え」をクリック(2回目)
  [アンマウント(派手なカウンター)]
  [マウント(普通のカウンター)]
  画面: 普通のカウンター:0回

こちらは回数が0回に戻りました。{isFancy && ...} と {!isFancy && ...} は div の1番目と2番目の子で、公式ドキュメントにあるとおり、どちらかが false のときもその位置は空いたまま残ります。そのため、片方の Counter が消え、もう片方の位置に別の Counter が現れた、という扱いになります。

同じ Counter でも、三項演算子なら回数が残り、&& を2つ並べると消えます。同じページの、違う位置に描画して state を分ける方法では、この性質を使って、2人のプレイヤーのスコアを別々にリセットする例が紹介されています。消すつもりがなければ三項演算子でまとめ、意図して分けたいなら && で位置を分けるか、key を変えます(key で state をリセットする方法)。

中身が同じでも、別の関数なら別の種類

5つ目は、Counter と中身がまったく同じ CounterCopy という関数を作り、三項演算子で出し分ける書き方です。

tsx
{useCopy ? <CounterCopy label="CounterCopy" /> : <Counter label="Counter" />}
text
  [マウント(Counter)]
  > カウンターを3回クリック
  画面: Counter:3回
  > 「切り替え」をクリック(1回目)
  [アンマウント(Counter)]
  [マウント(CounterCopy)]
  画面: CounterCopy:0回
  ボタンの DOM は前と同じ要素か: false
  > 「切り替え」をクリック(2回目)
  [アンマウント(CounterCopy)]
  [マウント(Counter)]
  画面: Counter:0回

描画される HTML の形は同じでも、回数は0回に戻りました。React が種類として比べているのは関数そのもので、コードの中身ではありません。

注意

コンポーネントの関数の中で、別のコンポーネントの関数を定義すると、親が描画されるたびに新しい関数ができます。React から見ると毎回別の種類になるので、その下の state が描画のたびに消えます。公式ドキュメントの注意では、コンポーネントの関数はファイルの一番外側で定義し、入れ子にしないよう勧めています。

ポイント

同じ位置に同じ種類(同じ関数、または同じ名前の要素)が描画され続けるかぎり、props が変わっても state は残ります。位置か種類のどちらかが変わると、その下の state はまとめて消えます。

​Posted by 86