state はスナップショットである

state 変数は、読んだり書いたりできる普通の JavaScript の変数のように見えるかもしれません。しかし、state はむしろ、スナップショットのように振る舞います。state をセットしても、既にある state 変数は変更されず、代わりに再レンダーがトリガされます。

このページで学ぶこと

  • state のセットが再レンダーをどのようにトリガするのか
  • state がいつどのように更新されるか
  • state がセットされた直後に更新されない理由
  • イベントハンドラが state の「スナップショット」にどのようにアクセスするのか

state のセットでレンダーがトリガされる

ユーザインターフェースとはクリックなどのユーザイベントに直接反応して更新されるものだ、と考えているかもしれません。React の動作は、このような考え方とは少し異なります。前のページで、state をセットすることで再レンダーを React に要求しているのだ、ということを見てきました。これは、インターフェースがイベントに応答するためには、state を更新する必要があることを意味します。

この例では、“Send” を押すと、setIsSent(true) が React に UI の再レンダーを指示します。

import { useState } from 'react';

export default function Form() {
  const [isSent, setIsSent] = useState(false);
  const [message, setMessage] = useState('Hi!');
  if (isSent) {
    return <h1>Your message is on its way!</h1>
  }
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      setIsSent(true);
      sendMessage(message);
    }}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

function sendMessage(message) {
  // ...
}

ボタンをクリックすると次のような処理が行われます:

  1. onSubmit イベントハンドラが実行されます。
  2. setIsSent(true)isSenttrue にセットし、新しいレンダーを予約します。
  3. React が新しい isSent の値を使ってコンポーネントを再レンダーします。

state とレンダーの関係をもう少し詳しく見ていきましょう。

レンダーは時間を切り取ってスナップショットを取る

「レンダーする」とは、React があなたのコンポーネント(関数)を呼び出すということです。関数から返される JSX は、その時点での UI のスナップショットのようなものです。その JSX 内の props、イベントハンドラ、ローカル変数はすべて、レンダー時の state を使用して計算されます

写真や映画のフレームとは違い、返される「UI のスナップショット」はインタラクティブです。イベントハンドラのような、入力に対する応答を指定するためのロジックが含まれています。React は画面をこのスナップショットに合わせて更新し、イベントハンドラを接続します。その結果として、ボタンを押すと JSX に書いたクリックハンドラがトリガされます。

React がコンポーネントを再レンダーする際には:

  1. React が再度あなたの関数を呼び出します。
  2. 関数は新しい JSX のスナップショットを返します。
  3. React はあなたの関数が返したスナップショットに合わせて画面を更新します。
  1. React が関数を実行
  2. スナップショットを計算
  3. DOM ツリーを更新

Illustrated by Rachel Lee Nabors

コンポーネントのメモリとしての state は、関数が終了したら消えてしまう通常の変数とは異なります。state は実際には React 自体の中で「生存」しています。まるで棚に保管しているかのように、関数の外部で存在し続けます。React がコンポーネントを呼び出すとき、React はその特定のレンダーに対する state のスナップショットを提供します。あなたのコンポーネントは、props やイベントハンドラの新たな一式を揃えた JSX という形で UI のスナップショットを返し、それらはすべてその特定のレンダー時の state の値を使って計算されます!

  1. state の更新を React に指示
  2. React が state の値を更新
  3. React がコンポーネントに state のスナップショットを渡す

Illustrated by Rachel Lee Nabors

これがどのように動作するかを示す小さな実験をしましょう。この例では、“+3” ボタンをクリックすると setNumber(number + 1) を 3 回呼び出すので、カウンタが 3 回インクリメントされると予想するかもしれません。

“+3” ボタンをクリックすると何が起こるか見てみましょう。

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

number がクリックごとに 1 しか増えていませんね!

state をセットしても、それが本当に変更されるのは次回のレンダーです。最初のレンダーでは number0 でした。だから、そのレンダーの onClick ハンドラにおいては、setNumber(number + 1) が呼ばれた後も number0 のままだったのです。

<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>

このボタンのクリックハンドラは、以下のように React に指示しています。

  1. setNumber(number + 1): number0 なので setNumber(0 + 1)
    • React は次回のレンダーで number1 に更新する準備をする。
  2. setNumber(number + 1): number0 なので setNumber(0 + 1)
    • React は次回のレンダーで number1 に更新する準備をする。
  3. setNumber(number + 1): number0 なので setNumber(0 + 1)
    • React は次回のレンダーで number1 に更新する準備をする。

setNumber(number + 1) を 3 回呼び出しましたが、今回のレンダーのイベントハンドラでは number は常に 0 なので、state を 3 回連続して 1 にセットしていることになります。これが、イベントハンドラが終了した後、React が number3 ではなく 1 とした上でコンポーネントを再レンダーする理由です。

もっと分かりやすくするために、頭の中でコード内の state 変数を実際の値に置換してみることもできます。このレンダーでは number という state 変数は 0 なので、イベントハンドラは次のようになっています。

<button onClick={() => {
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
}}>+3</button>

次のレンダーでは、number1 になるため、そちらのレンダーの クリックハンドラは、次のようになります。

<button onClick={() => {
setNumber(1 + 1);
setNumber(1 + 1);
setNumber(1 + 1);
}}>+3</button>

以上が、ボタンを再度クリックするとカウンタが 2 にセットされ、次のクリックでは 3 にセットされ、というようになる理由です。

時間経過と state

なかなか面白い話でした。それでは、このボタンをクリックするとアラートに何が表示されるか予想してみてください。

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        alert(number);
      }}>+5</button>
    </>
  )
}

上記で説明した置換メソッドを使えば、アラートには “0” と表示されることがわかりますね。

setNumber(0 + 5);
alert(0);

でも、アラートにタイマーを設定して、コンポーネントが再レンダーされた後に発火するようにしたらどうなるでしょうか? “0” と表示されるのか、“5” と表示されるのか推測してみてください。

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}

驚いたでしょうか? さきほどの置換メソッドを使ってみれば、アラートに渡された state が「スナップショット」であることが分かるでしょう。

setNumber(0 + 5);
setTimeout(() => {
alert(0);
}, 3000);

アラートが実行される時点では React に格納されている state は既に更新されているかもしれませんが、アラートはユーザがボタンを操作した時点での state のスナップショットを使ってスケジューリングされました!

イベントハンドラのコードが非同期であっても、レンダー内の state 変数の値は決して変わりませんそのレンダーの onClick 内では、setNumber(number + 5) が呼ばれた後も number の値は 0 のままです。その値は React があなたのコンポーネントを呼び出して UI の「スナップショットを取った」時に、「固定」されたのです。

ここで、このお陰でタイミングにまつわる問題が起きづらくなっている、という例をお示しします。以下のフォームは、5 秒の遅延後にメッセージを送信します。ここでこんなシナリオを想像してみてください:

  1. “Send” ボタンを押して、“Hello” というメッセージをアリスに送る。
  2. 5 秒の遅延が終わる前に、“To” フィールドの値を “Bob” に変更する。

alert に何が表示されると思いますか? “You said Hello to Alice” と表示されるのでしょうか? それとも “You said Hello to Bob” でしょうか? ここまでの知識に基づいて推測し、実際に試してみましょう。

import { useState } from 'react';

export default function Form() {
  const [to, setTo] = useState('Alice');
  const [message, setMessage] = useState('Hello');

  function handleSubmit(e) {
    e.preventDefault();
    setTimeout(() => {
      alert(`You said ${message} to ${to}`);
    }, 5000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        To:{' '}
        <select
          value={to}
          onChange={e => setTo(e.target.value)}>
          <option value="Alice">Alice</option>
          <option value="Bob">Bob</option>
        </select>
      </label>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

React は、レンダー内の state の値を「固定」し、イベントハンドラ内で保持します。コードが実行されている途中で state が変更されたかどうか心配する必要はありません。

しかし、再レンダー前に最新の state を読み取りたい場合はどうでしょうか? state 更新用関数を使うことができます。これについては次のページで説明します!

まとめ

  • state のセットは新しいレンダーをリクエストする。
  • React は state をコンポーネントの外側で、まるで棚に保管しておくかのようにして保持する。
  • useState を呼び出すと、React はそのレンダーのための state のスナップショットを返す。
  • 変数やイベントハンドラは複数レンダーをまたいで「生き残る」ことはない。すべてのレンダーは固有のイベントハンドラを持つ。
  • 各レンダー(およびその中の関数)からは、常に、React が そのレンダーに渡した state のスナップショットが「見える」。
  • レンダーされた JSX を考える時と同様にして、イベントハンドラ内の state を頭の中で実際の値に置換してみることができる。
  • 過去に作成されたイベントハンドラは、それが作成されたレンダーにおける state の値を持っている。

チャレンジ 1/1:
信号機を実装

以下は、ボタンが押されると切り替わる歩行者用信号機のコンポーネントです。

import { useState } from 'react';

export default function TrafficLight() {
  const [walk, setWalk] = useState(true);

  function handleClick() {
    setWalk(!walk);
  }

  return (
    <>
      <button onClick={handleClick}>
        Change to {walk ? 'Stop' : 'Walk'}
      </button>
      <h1 style={{
        color: walk ? 'darkgreen' : 'darkred'
      }}>
        {walk ? 'Walk' : 'Stop'}
      </h1>
    </>
  );
}

クリックハンドラに alert を追加してください。信号が緑で “Walk” と表示されている場合、ボタンをクリックすると “Stop is next” と表示され、信号が赤で “Stop” と表示されている場合、ボタンをクリックすると “Walk is next” と表示されるようにしてください。

alertsetWalk の前に置いた場合と後に置いた場合で、違いはありますか?