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 の前に置いた場合と後に置いた場合で、違いはありますか?