1. ホーム
  2. React

【React】stateが保持・リセットされる仕組み|UIツリーの「同じ位置」と key の関係を解説

Share

入力途中のフォームが勝手に空になったり、逆に「別のものを表示したはずなのに前の値が残っている」といった挙動に悩まされたことはないでしょうか。これらは React が state をどこに保管しているかを知ると、すべて同じルールで説明できます。この記事では、state がコンポーネントではなく UIツリーの「位置」 に紐づいていること、どんなときに保持されどんなときに破棄されるのか、そして意図的にリセットしたいときの key の使い方までを、動くコードで順に見ていきます。

state はコンポーネントの中ではなく React が持っている

useState で作った値は、一見すると関数コンポーネントの中の変数のように見えます。しかし関数コンポーネントは再レンダリングのたびに最初から実行し直されるので、関数の中のローカル変数だとしたら値は毎回消えてしまうはずです。実際にはそうならないのは、state の実体を React 側が保持しているからです。

では React はその値をどんな目印で管理しているのでしょうか。答えは「UIツリーの中の位置」です。React はレンダリング結果から、どの要素がどの親の何番目の子なのかという木構造(UIツリー)を組み立て、その位置ごとに state を対応づけて覚えています。コンポーネント名でも、変数名でもなく、ツリーのどこに描かれているかが state の住所になっているわけです。

ここから、React の挙動を説明する一本のルールが導けます。同じ位置に同じコンポーネントが描かれ続けるかぎり state は保たれ、位置からコンポーネントが消えたり別の種類のコンポーネントに変わったりすると state は破棄される、というものです。以降の例は、すべてこのルールの言い換えにすぎません。

試しに、同じ Counter を2つ並べてみます。書いているコンポーネントは1種類ですが、位置が2つあるので state も2つ独立して存在します。片方のカウントを増やしても、もう片方には影響しません。

App.jsx
import { useState } from 'react';

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

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

export default function App() {
  return (
    <div>
      {/* 同じ Counter でも「1番目」と「2番目」で別々の state を持つ */}
      <Counter />
      <Counter />
    </div>
  );
}

逆にいえば、React にとって重要なのは「その位置に何が居るか」だけです。書いた場所がソースコード上のどの行なのか、どんな条件式の中なのかは関係ありません。この感覚を持っておくと、次から見ていく一見ふしぎな挙動が納得しやすくなります。

見た目が変わっても同じ位置なら state は残る

まずは「保持される」側の例です。三項演算子で表示を切り替えるコードを書いてみます。チェックボックスをオンにすると装飾付きのカウンターに、オフにすると通常のカウンターに切り替わる、というよくある実装です。

App.jsx
import { useState } from 'react';

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

  return (
    <div style={{ color: isFancy ? 'crimson' : 'black' }}>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

export default function App() {
  const [isFancy, setIsFancy] = useState(false);

  return (
    <div>
      {/* true でも false でも、この位置に描かれるのは Counter */}
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}

      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={(e) => setIsFancy(e.target.checked)}
        />
        装飾する
      </label>
    </div>
  );
}

カウントを3まで進めてからチェックボックスを切り替えると、色は変わりますがカウントは3のままです。三項演算子で書き分けているので直感的にはリセットされそうですが、React から見ると「App が返すツリーの1番目の子が Counter である」という点は前後で変わっていません。同じ位置に同じコンポーネントなので、state は引き継がれます。

変わったのは isFancy という props だけです。props は毎回のレンダリングで新しく渡されるものなので、値が変わっても state には影響しません。「props が変わったら state が初期化される」という誤解はよくありますが、React はそうした動きをしません。

位置から消えると state も一緒に捨てられる

次は「破棄される」側です。三項演算子ではなく && を使って、コンポーネント自体を出したり消したりしてみます。

App.jsx
export default function App() {
  const [show, setShow] = useState(true);

  return (
    <div>
      {/* show が false のときは、この位置に何も描かれない */}
      {show && <Counter isFancy={false} />}

      <button onClick={() => setShow(!show)}>
        {show ? '隠す' : '表示する'}
      </button>
    </div>
  );
}

カウントを進めてから「隠す」を押し、もう一度「表示する」を押すと、カウントは0に戻っていますshowfalse になった瞬間、その位置には何も描かれなくなり、React は「もうこの位置に Counter は居ない」と判断して、対応する state を破棄します。あとから同じコンポーネントを描き直しても、それは新しく作られた別のインスタンスです。

この破棄はコンポーネントのアンマウントとして扱われるので、useEffect のクリーンアップ関数も実行されます。「タブを切り替えるたびに入力内容が消える」「モーダルを閉じて開き直すとフォームが空になる」といった挙動は、たいていこのパターンです。消したくない値であれば、次の節で触れるように親コンポーネントへ持ち上げる必要があります。

親のタグを変えるだけで子の state が消える

「位置」が意味するのはコンポーネントの並び順だけではありません。その位置に至るまでの親の連なりも含みます。そのため、子はまったく同じでも、囲んでいるタグの種類が変わると state はリセットされます。

App.jsx(切り替えると state が消える例)
export default function App() {
  const [isFancy, setIsFancy] = useState(false);

  return (
    <div>
      {isFancy ? (
        // div > Counter
        <div className="fancy">
          <Counter isFancy={true} />
        </div>
      ) : (
        // section > Counter (親のタグが違う)
        <section>
          <Counter isFancy={false} />
        </section>
      )}

      <button onClick={() => setIsFancy(!isFancy)}>切り替える</button>
    </div>
  );
}

切り替えるたびにカウントが0に戻ります。React は同じ位置に来た要素のタグ(型)を比べ、div から section のように違っていれば「別物になった」と判断して、その要素とすべての子孫を作り直します。Counter のコードには一切手を入れていなくても、親が作り直されれば子も巻き添えになるということです。

ちなみに、className だけが違うのであればタグは同じなので state は残ります。上の例で sectiondiv に直せば、カウントは維持されるようになります。デザイン調整のつもりでマークアップを書き換えたら入力内容が消えるようになった、というときはここを疑ってみてください。

コンポーネントの中でコンポーネントを定義してはいけない

state が意図せずリセットされる原因として特に見つけにくいのが、コンポーネントを別のコンポーネントの中で定義してしまうケースです。関数の中に関数を書けてしまう以上、文法的には何のエラーも出ませんが、React の挙動としては壊れます。

App.jsx(入力が毎回消えてしまう書き方)
import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);

  // NG: App が再レンダリングされるたびに、新しい TextField 関数が作られる
  function TextField() {
    const [text, setText] = useState('');

    return (
      <input value={text} onChange={(e) => setText(e.target.value)} />
    );
  }

  return (
    <div>
      <TextField />
      <button onClick={() => setCount(count + 1)}>
        押した回数: {count}
      </button>
    </div>
  );
}

入力欄に文字を打ってからボタンを押すと、打った文字がすべて消えますApp が再レンダリングされるたびに function TextField の評価が走り、毎回まったく別の関数オブジェクトが作られるためです。React は同じ位置に来たコンポーネントを「同じ型かどうか」で比較しますが、前回の TextField と今回の TextField は別の関数なので、別のコンポーネントに置き換わったと判断されてしまいます。

対処はシンプルで、コンポーネントの定義は必ずファイルのトップレベルに置くことです。こうすれば関数オブジェクトは一度しか作られないので、レンダリングをまたいでも同じ型として認識され、state が維持されます。

App.jsx(トップレベルに出した書き方)
import { useState } from 'react';

// OK: モジュールの評価時に一度だけ作られる
function TextField() {
  const [text, setText] = useState('');

  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <TextField />
      <button onClick={() => setCount(count + 1)}>
        押した回数: {count}
      </button>
    </div>
  );
}

親の値を使いたいという理由で内側に定義してしまうことがありますが、それは props で渡せば済む話です。同じ理由で、styled(Foo) のようにコンポーネントを生成する関数を、レンダリング関数の中で呼ぶのも避けてください。呼ぶたびに新しいコンポーネントができあがるので、結果は上のNG例と同じになります。

同じ位置のまま state をリセットしたいとき

ここまでは「消したくないのに消える」話でしたが、実務では逆に「切り替えたのだから消えてほしい」場面もあります。たとえばチャットの宛先を切り替えたとき、前の相手に向けて書きかけたメッセージがそのまま残っていたら事故のもとです。同じ Chat コンポーネントを同じ位置に描いている以上、そのままでは state は保持されてしまいます。

あえて別の位置に描き分ける

一つ目の方法は、条件によって描く位置そのものを変えることです。下のように2箇所に書き分ければ、React にとっては別の位置なので state も別々に管理されます。

App.jsx(位置で分ける)
return (
  <div>
    {/* 1番目の位置と2番目の位置なので state は共有されない */}
    {isPlayerA && <Counter isFancy={false} />}
    {!isPlayerA && <Counter isFancy={false} />}
  </div>
);

ただし、この書き方が使えるのは切り替え先が2〜3個に固定されているときだけです。宛先が動的なリストから選ばれるような場合には現実的ではありません。

key を変えて「別物だ」と伝える

より汎用的なのが key を使う方法です。key はリスト表示で使うものというイメージが強いのですが、本来の役割は「同じ位置に来た要素が、前回と同じものかどうか」を React に教えることです。リストの中でなくても指定できますし、key が変われば React は前のコンポーネントを破棄して新しく作り直します。

Messenger.jsx
import { useState } from 'react';

const contacts = [
  { id: 1, name: '田中' },
  { id: 2, name: '鈴木' },
  { id: 3, name: '佐藤' },
];

function Chat({ contact }) {
  const [text, setText] = useState('');

  return (
    <div>
      <h2>{contact.name} さんへ</h2>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={() => setText('')}>送信</button>
    </div>
  );
}

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);

  return (
    <div>
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button onClick={() => setTo(contact)}>{contact.name}</button>
          </li>
        ))}
      </ul>

      {/* 宛先が変わると key も変わり、Chat が作り直されて入力欄が空になる */}
      <Chat key={to.id} contact={to} />
    </div>
  );
}

key={to.id} を外すと、宛先を切り替えても書きかけの文面が残り続けます。付けておけば、宛先ごとに独立した Chat として扱われるので、切り替えた瞬間に text は初期値の空文字に戻ります。ログインとサインアップでフォームを切り替えるようなケースでも、key"login""signup" のような文字列を渡すだけで、入力内容の持ち越しを防げます。

なお、この key はグローバルに一意である必要はありません。React が比べるのは同じ親の中の兄弟どうしだけなので、別の場所で同じ key が使われていても問題ありません。

保持されるか、リセットされるかの一覧

ここまで見てきたパターンを整理します。判断に迷ったら、「レンダリング結果のツリーを描いてみて、その位置に前回と同じ型のコンポーネントが居るか」を確認するのが確実です。

状況state はどうなるか理由
props だけが変わった保持される位置もコンポーネントの型も変わっていない
三項演算子で同じコンポーネントを描き分けた保持されるどちらの分岐でも同じ位置に同じ型が来る
className や style だけ変えた保持されるタグ(型)は同じなので要素は再利用される
条件を false にして描画をやめたリセットされる位置からコンポーネントが消え、アンマウントされる
囲むタグを div から section に変えたリセットされる親の型が変わり、子孫ごと作り直される
同じ位置で別のコンポーネントに差し替えたリセットされる型が違えば React は別物として扱う
コンポーネントを他のコンポーネント内で定義した毎回リセットされるレンダリングのたびに別の関数が作られ、型が変わる
同じ位置で key の値を変えたリセットされるkey が違えば別のコンポーネントとみなされる

位置が変わっても値を残したいときは親に預ける

表示のオン・オフや画面構造の変更をどうしても行いたいけれど、値は残したい。そういうときは、そもそも消える場所に state を置かないのが正攻法です。値を親コンポーネントへ移し、子には props として渡す形にします。いわゆるリフトアップです。

App.jsx(値を親に持たせる)
import { useState } from 'react';

function Panel({ text, onChangeText }) {
  // 自分では state を持たず、受け取った値を表示するだけ
  return (
    <textarea value={text} onChange={(e) => onChangeText(e.target.value)} />
  );
}

export default function App() {
  const [open, setOpen] = useState(true);
  // Panel が消えても、この state は App に残り続ける
  const [text, setText] = useState('');

  return (
    <div>
      <button onClick={() => setOpen(!open)}>
        {open ? '閉じる' : '開く'}
      </button>

      {open && <Panel text={text} onChangeText={setText} />}
    </div>
  );
}

Panel は表示のたびにマウントとアンマウントを繰り返しますが、値そのものは App が持っているので消えません。閉じて開き直しても入力内容が復元されます。ここでのポイントは、state の寿命は、それを持っているコンポーネントが描かれ続ける期間と一致するということです。「この値はいつまで生きていてほしいか」を先に決めれば、どのコンポーネントに置くべきかは自然に決まります。

ページ遷移をまたいで保持したい、リロードしても残したい、という要求であれば、React のツリーの外に出すしかありません。URL のクエリパラメータや sessionStorage、あるいは状態管理ライブラリが選択肢になります。React の state はあくまでツリーの構造と運命をともにする、という前提で設計するのが安全です。

状態を持つコンポーネントを条件付きで描くときの考え方

最後に、設計時に意識しておきたい点をまとめます。state を持つコンポーネントを条件付きで描くとき、判断の起点になるのは「切り替えの前後で、それは同じものの続きなのか、別のものなのか」という問いです。

同じものの続きであれば、条件分岐で囲み直したりタグ構造を変えたりせず、props だけを差し替えます。まったく別のものであれば、位置を分けるか key を変えて、React に明示的に伝えます。どちらとも言えない、つまり「見た目は同じだけど扱っている対象は別」という場合こそ key の出番で、対象を識別できる ID をそのまま key に渡すのが定石です。

また、リセットが起きたときに困るのは state だけではありません。アンマウントされれば useEffect のクリーンアップが走り、再マウント時には再びエフェクトが実行されます。データ取得を伴うコンポーネントを何度も作り直すと、そのぶんリクエストも増えます。key を変えるという判断は「作り直しの指示」であることを意識して使ってください。

まとめ

React の state は、コンポーネントの内部に閉じ込められているのではなく、React が UIツリーの「位置」に紐づけて保持しています。そのため、同じ位置に同じ型のコンポーネントが描かれ続けるかぎり state は保たれ、props が変わっても、三項演算子で書き分けても、className を切り替えてもリセットされません。逆に、条件を false にして描画をやめた場合、囲むタグを div から section に変えた場合、同じ位置で別のコンポーネントに差し替えた場合は、位置に対応する state が破棄されます。とりわけコンポーネントを他のコンポーネントの中で定義すると、レンダリングのたびに別の関数が作られて毎回リセットされるため、定義は必ずファイルのトップレベルに置いてください。意図的にリセットしたいときは、描く位置を分けるか、key に対象を識別できる値を渡します。key はリスト専用ではなく、フォームの切り替えのような場面でも使える「同じ位置のものを別物として扱う」ための仕組みです。反対に、表示のオン・オフをまたいで値を残したいなら、state を親コンポーネントへリフトアップして、消えない場所に置くのが確実です。

参考ページ