state の保持とリセット

state は複数のコンポーネント間で独立しています。React は UI ツリー内の各コンポーネントの位置に基づいて、どの state がどのコンポーネントに属するか管理します。再レンダーをまたいでどのようなときに state を保持し、どのようなときにリセットするのか、制御することができます。

このページで学ぶこと

  • React が state の保持とリセットを行うタイミング
  • React にコンポーネントの state のリセットを強制する方法
  • key とタイプが state の保持にどのように影響するか

state はレンダーツリー内の位置に結びついている

React はあなたの UI のコンポーネント構造をレンダーツリーとしてビルドします。

コンポーネントに state を与えると、その state はそのコンポーネントの内部で「生存」しているように思えるかもしれません。しかし、実際には state は React の中に保持されています。React は、「レンダーツリー内でそのコンポーネントがどの位置にあるか」に基づいて、保持している各 state を正しいコンポーネントに関連付けます。

以下のコードには <Counter /> JSX タグは 1 つしかありませんが、それが 2 つの異なる位置にレンダーされています。

import { useState } from 'react';

export default function App() {
  const counter = <Counter />;
  return (
    <div>
      {counter}
      {counter}
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

ツリーとしては、これは以下のように見えます。

React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。子ノードはいずれも 'Counter' であり、値が 0 の 'count' を state として持っている。
React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。子ノードはいずれも 'Counter' であり、値が 0 の 'count' を state として持っている。

React ツリー

これらはツリー内の別々の位置にレンダーされているため、2 つの別々のカウンタとして動作します。React を使用する上でこのような位置のことについて考える必要はめったにありませんが、React がどのように機能するかを理解することは有用です。

React では、画面上の各コンポーネントは完全に独立した state を持ちます。例えば、Counter コンポーネントを 2 つ横に並べてレンダーすると、それぞれが別個に、独立した score および hover という state を持つことになります。

両方のカウンタをクリックしてみて、互いに影響していないことを確かめてください。

import { useState } from 'react';

export default function App() {
  return (
    <div>
      <Counter />
      <Counter />
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

ご覧のように、カウンタのうち 1 つが更新されると、そのコンポーネントの state だけが更新されます。

React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。左の子は 'Counter' で、値が 0 の 'count' を state として持つ。右の子は 'Counter' で、値が 1 の 'count' を state として持つ。右の子の state バブルは黄色でハイライトされており、その値が更新されたことを示している。
React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。左の子は 'Counter' で、値が 0 の 'count' を state として持つ。右の子は 'Counter' で、値が 1 の 'count' を state として持つ。右の子の state バブルは黄色でハイライトされており、その値が更新されたことを示している。

state の更新

React は、同じコンポーネントをツリー内の同じ位置でレンダーしている限り、その state を保持し続けます。これを確認するため、両方のカウンタを増加させてから、“Render the second counter” のチェックボックスのチェックを外して 2 つ目のコンポーネントを削除し、再びチェックを入れて元に戻してみてください。

import { useState } from 'react';

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      <Counter />
      {showB && <Counter />} 
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={e => {
            setShowB(e.target.checked)
          }}
        />
        Render the second counter
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

2 つ目のカウンタのレンダーをやめた瞬間、その state は完全に消えてしまいます。これは、React がコンポーネントを削除する際にその state も破棄するからです。

React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。左の子は 'Counter' で、値が 0 の 'count' を state として持つ。右の子はパッと消えたようになっており、コンポーネントがツリーから削除されたことを示している。
React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。左の子は 'Counter' で、値が 0 の 'count' を state として持つ。右の子はパッと消えたようになっており、コンポーネントがツリーから削除されたことを示している。

コンポーネントの削除

”Render the second counter” にチェックを入れると、2 つ目の Counter とその state が初期化され (score = 0)、DOM に追加されます。

React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。左の子は 'Counter' で、値が 0 の 'count' state を持つ。右の子も 'Counter' であり、値が 0 の 'count' state を持つ。右の子は全体が黄色くハイライトされており、今まさにツリーに追加されたことを示している。
React コンポーネントツリーを表す図。ルートノードは 'div' であり、2 つの子を持つ。左の子は 'Counter' で、値が 0 の 'count' state を持つ。右の子も 'Counter' であり、値が 0 の 'count' state を持つ。右の子は全体が黄色くハイライトされており、今まさにツリーに追加されたことを示している。

コンポーネントを追加する

React は、UI ツリーの中でコンポーネントが当該位置にレンダーされ続けている間は、そのコンポーネントの state を維持します。もし削除されたり、同じ位置に別のコンポーネントがレンダーされたりすると、React は state を破棄します。

同じ位置の同じコンポーネントは state が保持される

この例のコードには、<Counter /> タグが 2 つあります。

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <Counter isFancy={true} /> 
      ) : (
        <Counter isFancy={false} /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

チェックボックスにチェックを入れたり消したりしても、カウンタの state はリセットされません。isFancytrue であろうと false であろうと、ルートの App コンポーネントが返す div の最初の子は常に <Counter /> だからです。

矢印で遷移している 2 つのセクションで構成される図。各セクションには、'App' とラベルの付いた親と、isFancy というラベルの付いた state ボックスが表示されている。このコンポーネントには 'div' とラベル付けされた 1 つの子があり、その下には isFancy と書かれたボックス(紫)があり下に props として渡されることを示している。どちらのセクションでも最後には 'count' = 3 という state ボックスを持つ 'Counter' がある。図の左側のセクションでは何も強調表示されておらず、isFancy の親状態の値は false。図の右側のセクションでは、isFancy の親の状態値が true に変更されて黄色でハイライトされており、下の props バブルも同様に true に変更されてハイライトされている。
矢印で遷移している 2 つのセクションで構成される図。各セクションには、'App' とラベルの付いた親と、isFancy というラベルの付いた state ボックスが表示されている。このコンポーネントには 'div' とラベル付けされた 1 つの子があり、その下には isFancy と書かれたボックス(紫)があり下に props として渡されることを示している。どちらのセクションでも最後には 'count' = 3 という state ボックスを持つ 'Counter' がある。図の左側のセクションでは何も強調表示されておらず、isFancy の親状態の値は false。図の右側のセクションでは、isFancy の親の状態値が true に変更されて黄色でハイライトされており、下の props バブルも同様に true に変更されてハイライトされている。

App の state を更新しても、Counter は同じ位置にあるためリセットされない

同じコンポーネントが同じ位置にあるので、React の観点からは同じカウンタだというわけです。

落とし穴

React にとって重要なのは JSX マークアップの位置ではなく UI ツリー内の位置であるということを覚えておいてください。このコンポーネントからは、if の内側と外側で、2 つの return 文から 2 つの異なる <Counter /> JSX タグが返されています。

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  if (isFancy) {
    return (
      <div>
        <Counter isFancy={true} />
        <label>
          <input
            type="checkbox"
            checked={isFancy}
            onChange={e => {
              setIsFancy(e.target.checked)
            }}
          />
          Use fancy styling
        </label>
      </div>
    );
  }
  return (
    <div>
      <Counter isFancy={false} />
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

チェックボックスにチェックを入れると state がリセットされると思われるかもしれませんが、そうはなりません。これは、これらの両方の <Counter /> タグが同じ位置でレンダーされているためです。あなたの関数内で条件分岐がどのように書かれているか、React には分かりません。React に「見える」のは、返されるツリーだけです。

どちらの場合も、App コンポーネントは、<Counter /> を最初の子として持つ <div> を返します。React にとって、これら 2 つのカウンタは、「ルートの最初の子の最初の子」という、同じ「住所」を持っています。これが、あなたがどのようにロジックを構築しているかに関係なく、前回のレンダーと次のレンダーの間で React がコンポーネントを対応付ける方法なのです。

同じ位置の異なるコンポーネントは state をリセットする

この例では、チェックボックスにチェックを入れると、<Counter><p> に置き換わります。

import { useState } from 'react';

export default function App() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? (
        <p>See you later!</p> 
      ) : (
        <Counter /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isPaused}
          onChange={e => {
            setIsPaused(e.target.checked)
          }}
        />
        Take a break
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

ここでは、同じ位置で異なる種類のコンポーネントを切り替えています。最初は <div> の最初の子は Counter でした。それを p と入れ替えると、React は UI ツリーから Counter を削除し、その state を破棄します。

矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその唯一の子である 'Counter' があり、カウンタには値が 3 になった 'count' state がある。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があるが、今度は 'p' と書かれた新しい子ができており、黄色でハイライトされている。
矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその唯一の子である 'Counter' があり、カウンタには値が 3 になった 'count' state がある。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があるが、今度は 'p' と書かれた新しい子ができており、黄色でハイライトされている。

Counterp に変わると、Counter は削除され、p が追加される

矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその子である 'p' がある。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があるが、今度は 'Count' と書かれた新しい子ができており黄色でハイライトされている。その 'count' state は 0 となっている。
矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその子である 'p' がある。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があるが、今度は 'Count' と書かれた新しい子ができており黄色でハイライトされている。その 'count' state は 0 となっている。

戻すときは、p が削除され、Counter が追加される

また、同じ位置で異なるコンポーネントをレンダーすると、そのサブツリー全体の state がリセットされます。これがどのように動作するかを確認するために、以下でカウンタを増やしてからチェックボックスにチェックを入れてみてください:

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <div>
          <Counter isFancy={true} /> 
        </div>
      ) : (
        <section>
          <Counter isFancy={false} />
        </section>
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

チェックボックスをクリックするとカウンタの state がリセットされます。Counter をレンダーしていることは同じでも、<div> の最初の子が section から div に変わっています。子側の section が DOM から削除されたとき、その下のツリー全体(Counter とその state を含む)も破棄されたのです。

矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその子である 'section' がある。さらにその子に 'Counter' があり、'count' の state ボックスは 3 となっている。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があり、今度は別の 'div' が新しい子となってハイライトされている。さらにその子に 'Counter' があって黄色でハイライトされており、その 'count' の state ボックスは 0 となっている。
矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその子である 'section' がある。さらにその子に 'Counter' があり、'count' の state ボックスは 3 となっている。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があり、今度は別の 'div' が新しい子となってハイライトされている。さらにその子に 'Counter' があって黄色でハイライトされており、その 'count' の state ボックスは 0 となっている。

sectiondiv に変わると、section は削除され、新しい div が追加される

矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその子である別の 'div' がある。さらにその子に 'Counter' があり、'count' の state ボックスは 0 となっている。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があり、今度は 'section' が新しい子となってハイライトされている。さらにその子に 'Counter' があって黄色でハイライトされており、その 'count' の state ボックスは 0 となっている。
矢印で遷移する 3 セクションから構成される図。最初のセクションには React コンポーネントとして 'div' とその子である別の 'div' がある。さらにその子に 'Counter' があり、'count' の state ボックスは 0 となっている。中央のセクションにも親の 'div' があるが、子が消失している。最後のセクションにも 'div' があり、今度は 'section' が新しい子となってハイライトされている。さらにその子に 'Counter' があって黄色でハイライトされており、その 'count' の state ボックスは 0 となっている。

戻すときは div は削除され、新しい section が追加される

覚えておくべきルールとして、再レンダー間で state を維持したい場合、ツリーの構造はレンダー間で「合致」する必要があります。構造が異なる場合、React がツリーからコンポーネントを削除するときに state も破棄されてしまいます。

落とし穴

これがコンポーネント関数の定義をネストしてはいけない理由でもあります。

以下では、MyTextField コンポーネント関数が MyComponent内部で定義されています。

import { useState } from 'react';

export default function MyComponent() {
  const [counter, setCounter] = useState(0);

  function MyTextField() {
    const [text, setText] = useState('');

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

  return (
    <>
      <MyTextField />
      <button onClick={() => {
        setCounter(counter + 1)
      }}>Clicked {counter} times</button>
    </>
  );
}

ボタンをクリックするたびに、入力フィールドの state が消えてしまいます! これは、MyComponent がレンダーされるたびに異なる MyTextField 関数が作成されているためです。同じ位置に異なるコンポーネントをレンダーしているので、React はそれより下のすべての state をリセットします。これはバグやパフォーマンスの問題につながります。この問題を避けるために、常にコンポーネント関数はトップレベルで宣言し、定義をネストしないようにしてください。

同じ位置で state をリセット

デフォルトでは、React はコンポーネントが同じ位置にある間、その state を保持します。通常、これがまさにあなたが望むものであり、デフォルト動作として妥当です。しかし時には、コンポーネントの state をリセットしたい場合があります。以下の、2 人のプレーヤに交替でスコアを記録させるアプリを考えてみましょう。

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter person="Taylor" />
      ) : (
        <Counter person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

現在のところ、プレーヤを変更してもスコアが保持されています。2 つの Counter は同じ位置に表示されるため、React は person という props が変更されただけの同一の Counter であると認識します。

しかし、概念的には、このアプリでは、この 2 つのカウンタは別物であるべきです。UI 上の同じ場所に表示されているにせよ、一方は Taylor のカウンタで、もう一方は Sarah のカウンタなのです。

これらを切り替えるときに state をリセットする方法は、2 つあります。

  1. コンポーネントを異なる位置でレンダーする
  2. key を使って各コンポーネントに明示的な識別子を付与する

オプション 1:異なる位置でコンポーネントをレンダー

これら 2 つの Counter を互いに独立させたい場合、レンダーを 2 つの別の位置で行うことで可能です。

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA &&
        <Counter person="Taylor" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

  • 最初 isPlayerAtrue です。そのため、1 番目の位置に対して Counter の state が保持され、2 番目は空です。
  • “Next player” ボタンをクリックすると、最初の位置がクリアされ、2 番目の位置に Counter が入ります。
React コンポーネントツリーを表す図。親は 'Scoreboard' という名前であり isPlayerA という state ボックスの値は 'true' である。唯一の子は左側に配置される 'Counter' であり、'count' という state ボックスの値は 0 である。左の子供全体が黄色でハイライトされており、追加されたことを示している。
React コンポーネントツリーを表す図。親は 'Scoreboard' という名前であり isPlayerA という state ボックスの値は 'true' である。唯一の子は左側に配置される 'Counter' であり、'count' という state ボックスの値は 0 である。左の子供全体が黄色でハイライトされており、追加されたことを示している。

初期 state

React コンポーネントツリーを表す図。親は 'Scoreboard' という名前であり isPlayerA という state ボックスの値は 'false' である。このボックスは黄色でハイライトされており、変更があったことを示している。左の子は消失しており、一方で右に別の子が追加されており、黄色でハイライトされている。新しい子は 'Counter' であり、'count' という state ボックスの値は 0 である。
React コンポーネントツリーを表す図。親は 'Scoreboard' という名前であり isPlayerA という state ボックスの値は 'false' である。このボックスは黄色でハイライトされており、変更があったことを示している。左の子は消失しており、一方で右に別の子が追加されており、黄色でハイライトされている。新しい子は 'Counter' であり、'count' という state ボックスの値は 0 である。

”Next” をクリック

React コンポーネントツリーを表す図。親は 'Scoreboard' という名前であり isPlayerA という state ボックスの値は 'true' である。このボックスは黄色でハイライトされており、変更があったことを示している。左に子が追加されており、黄色でハイライトされている。新しい子は 'Counter' であり、'count' という state ボックスの値は 0 である。右の子は消失している。
React コンポーネントツリーを表す図。親は 'Scoreboard' という名前であり isPlayerA という state ボックスの値は 'true' である。このボックスは黄色でハイライトされており、変更があったことを示している。左に子が追加されており、黄色でハイライトされている。新しい子は 'Counter' であり、'count' という state ボックスの値は 0 である。右の子は消失している。

再び “Next” をクリック

Counter の state は DOM から削除されるたびに破棄されます。これがボタンをクリックするたびにカウントがリセットされる理由です。

この解決策は、同じ場所でレンダーされる独立したコンポーネントが数個しかない場合には便利です。この例では 2 つしかないので、両方を JSX 内で別々にレンダーしても大変ではありません。

オプション 2:key で state をリセットする

コンポーネントの state をリセットする一般的な方法がもうひとつあります。

リストをレンダーする際に key を使ったのを覚えているでしょうか。key はリストのためだけのものではありません! どんなコンポーネントでも React がそれを識別するために使用できるのです。デフォルトでは、React は親コンポーネント内での順序(「1 番目のカウンタ」「2 番目のカウンタ」)を使ってコンポーネントを区別します。しかし、key を使うことで、カウンタが単なる 1 番目のカウンタや 2 番目のカウンタではなく特定のカウンタ、例えば Taylor のカウンタである、と React に伝えることができます。このようにして、React は Taylor のカウンタがツリーのどこにあっても識別できるようになるのです。

この例では、2 つの <Counter /> は JSX の同じ場所にあるにもかかわらず、state を共有していません:

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

Taylor と Sarah を切り替えたときに state が保持されなくなりました。異なる key を指定したからです。

{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}

key を指定することで、親要素内の順序ではなく、key 自体を位置に関する情報として React に使用させることができます。これにより、JSX で同じ位置にレンダーしても、React はそれらを異なるカウンタとして認識するため、state が共有されてしまうことはありません。カウンタが画面に表示されるたびに、新しい state が作成されます。カウンタが削除されるたびに、その state は破棄されます。切り替えるたびに、何度でも state がリセットされます。

補足

key はグローバルに一意である必要はなく、親要素内での位置を指定しているだけであることを覚えておきましょう。

key でフォームをリセットする

key を使って state をリセットすることは、フォームを扱う際に特に有用です。

このチャットアプリでは、<Chat> コンポーネントがテキスト入力フィールド用の state を持っています。

import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat contact={to} />
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];

入力フィールドに何か入力してから、“Alice” または “Bob” をクリックして別の送信先を選択してみてください。<Chat> がツリーの同じ位置にレンダーされているため、入力した state が保持されたままになっていることが分かります。

多くのアプリではこれが望ましい動作でしょうが、チャットアプリでは違います! ミスクリックのせいで既に入力したメッセージを誤った相手に送ってしまうことは避けたいでしょう。これを修正するために、key を追加します。

<Chat key={to.id} contact={to} />

これにより、異なる送信先を選択したときに、Chat コンポーネントが、その下のツリーにあるあらゆる state も含めて最初から再作成されるようになります。React は DOM 要素についても再利用するのではなく再作成します。

これで、送信先を切り替えると常にテキストフィールドがクリアされるようになります。

import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat key={to.id} contact={to} />
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];

さらに深く知る

削除されたコンポーネントの state の保持

実際のチャットアプリでは、ユーザが以前の送信先を再度選択したときに入力欄の state を復元できるようにしたいでしょう。表示されなくなったコンポーネントの state を「生かしておく」方法はいくつかあります。

  • 現在のチャットだけでなくすべてのチャットをレンダーしておき、CSS で残りのすべてのチャットを非表示にすることができます。チャットはツリーから削除されないため、ローカル state も保持されます。この解決策はシンプルな UI では適していますが、非表示のツリーが大きく DOM ノードがたくさん含まれている場合は非常に遅くなります。
  • state をリフトアップすることで、各送信先に対応する書きかけのメッセージを親コンポーネントで保持することができます。この方法では、重要な情報を保持しているのは親の方なので、子コンポーネントが削除されても問題ありません。これが最も一般的な解決策です。
  • また、React の state に加えて別の情報源を利用することもできます。例えば、ユーザがページを誤って閉じたとしてもメッセージの下書きが保持されるようにしたいでしょう。これを実装するために、Chat コンポーネントが localStorage から読み込んで state を初期化し、下書きを保存するようにできます。

どの戦略を選ぶ場合でも、Alice とのチャットと Bob とのチャットは概念的には異なるものなので、現在の送信先に基づいて <Chat> ツリーに key を付与することは妥当です。

まとめ

  • React は、同じコンポーネントが同じ位置でレンダーされている限り、state を保持する。
  • state は JSX タグに保持されるのではない。JSX を置くツリー内の位置に関連付けられている。
  • 異なる key を与えることで、サブツリーの state をリセットするよう強制することができる。
  • コンポーネント定義をネストさせてはいけない。さもないと state がリセットされてしまう。

チャレンジ 1/5:
入力テキストの消失を修正

この例では、ボタンを押すとメッセージが表示されます。が、ボタンを押すことでどういうわけか入力欄がリセットされてしまいます。なぜこれが起こるのでしょうか? ボタンを押しても入力テキストがリセットされないように修正してください。

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  if (showHint) {
    return (
      <div>
        <p><i>Hint: Your favorite city?</i></p>
        <Form />
        <button onClick={() => {
          setShowHint(false);
        }}>Hide hint</button>
      </div>
    );
  }
  return (
    <div>
      <Form />
      <button onClick={() => {
        setShowHint(true);
      }}>Show hint</button>
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}