📸

【React】setStateはStateを変更しない?

2022/01/21に公開

とある日の晩

Reactチュートリアルを終えたばかりの友人に、簡単なTodoアプリの実装デモを通じてpropsとstateについて説明していたときのことです。

useState APIの使い方を理解してもらうため、こんな感じのコードを書いて見せました。初期値に'洗濯物'を持つTodoリストで、ボタンを押すと'hoge'という項目が追加されるというものです。

export const TodoList = () => {
  const [todos, setTodos] = React.useState(["洗濯物"]);

  const addTodo = (newTodo: string) => {
    console.log('Before:', todos);
    setTodos((prevState) => [newTodo, ...prevState]);
    console.log('After:', todos);
  };

  return (
    <>
      <ul>
        {todos.map((todo) => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
      <button onClick={() => addTodo("hoge")}>hogeを追加</button>
    </>
  );
};

特に以下の部分のコードで、setStateがstateを変更することをコンソール上でも確認しようとしていました。

  const addTodo = (newTodo: string) => {
    console.log('Before:', todos);
    setTodos((prevState) => [newTodo, ...prevState]);
    console.log('After:', todos);
  };

ここで期待していた出力は以下のようなものです。

Before: ['洗濯物']
After: ['洗濯物', 'hoge']

しかし驚いたことに、実際の出力は以下のようなものでした。

Before: ['洗濯物']
After: ['洗濯物']

なんと、BeforeとAfterの間ではsetTodos((prevState) => [newTodo, ...prevState])が呼び出されているはずなのに、stateの中身はBeforeとAfterで変わらないではありませんか!

「あれ、なにかエラーでも引き起こしたかな」と思ってアプリケーション画面を見てみると、きちんとTodoリストに'hoge'が追加されています。

つまり、「アプリケーション自体は自分の期待通りに動いているが、stateの挙動をじっくり観察すると自分ではよく分からない振る舞い方をしている」ということでした。完全に想定外です。偉そうにstateについて説明していたのに、私はstateの挙動についてちゃんと理解していませんでした。

スナップショットとしてのState

このstateの挙動について後日調べてみたところ、ß版の新しいReact公式ドキュメントに詳しく説明されていたので、今回はその内容を紹介します。この投稿の画像やCodeSandboxはすべてここから引用しました。

https://beta.reactjs.org/learn/state-as-a-snapshot

結論から言うと、私が押さえておくべきだったポイントは次の2点です。

  1. setStateはStateを直接変更する関数ではなく、新しいStateを用いた再レンダリングを引き起こす関数である
  2. 同一レンダー内で、stateの値は不変である

特に2の特徴について、ドキュメントでは「普通のJavaScript変数とは異なり、stateはスナップショットのような振る舞い方をする」と表現されています。

State variables might look like regular JavaScript variables that you can read and write to. However, state behaves more like a snapshot.

「スナップショット」は日本語的に理解しづらい言葉ですが、私は「連続的に変化するものについて、その一瞬を切り取ったもの」というふうに理解しました。そしてこれを理解するために、次のCodeSandboxが役に立ちました。

これは、ボタンを押すと次の2つの処理が同時に発動するコードです。

  1. stateであるnumberに5が足される(numberの初期値は0)
  2. 3秒後にstateを表示するアラートが出現する

一見、setNumberから時間が経っているのでアラートには5が足された数字が表示されそうな感じがしますが、そうではありません。たとえば1回目にボタンを押したときには、アラートでは0が表示されます。なぜなら、onClickイベントハンドラーが発動するときのnumberは0だからです

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

これが「スナップショットのような振る舞い方をする」ということです。同一レンダー内ではstateは不変であり、number = 0としてレンダーされたコンポーネント内ではすべてのnumberに0が代入されます。このとき、コンポーネントはまるでnumber = 0である一瞬を切り取ったかのような振る舞いをしているのです。

レンダリングとはスナップショットの「撮影」である

これを踏まえると、stateだけでなくJSX全体がUIコンポーネントのスナップショットを表現していることがよくわかります。そもそもReactコンポーネントは関数としてただ宣言されているだけのものとして記述されており、そこに複雑な変更についての表現は一切ありません。よく考えてみればuseStateでstate変数を定義するときも、constを用いて定数として表現するのでした。

レンダリングが起きるたびにReactはstateを用いて新しいスナップショットを「撮影」し、古いスナップショットとの差分を確認して画面を更新します。

ちなみに、再レンダリングにおけるstateの振る舞いについて、ドキュメントでは「関数が返った後に消えてしまう通常の変数とは異なり、まるで棚の上にあるかのようにReact自身の中に”生き残る”」と説明されています。

As a component’s memory, state is not like a regular variable that disappears after your function returns. State actually “lives” in React itself—as if on a shelf!—outside of your function.

まとめ

というわけで、当初の疑問だったTodoリストのstateの振る舞いは、もはや当然のことだと感じます。なぜなら最初にボタンを押したときのTodoリストは、todos = ['洗濯物']のときのスナップショットだったのですから。

  const addTodo = (newTodo: string) => {
    console.log('Before:', todos);
    setTodos((prevState) => [newTodo, ...prevState]);
    console.log('After:', todos);
  };
  
  // 1回目にボタンを押したときの振る舞い方
  const addTodo = (newTodo: string) => {
    console.log('Before:', ['洗濯物']);
    setTodos((['洗濯物']) => [newTodo, ...['洗濯物']]);
    console.log('After:', ['洗濯物']);
  };
  1. setStateはStateを直接変更する関数ではなく、新しいStateを用いた再レンダリングを引き起こす関数である
  2. 同一レンダー内で、stateの値は不変である

上記の2点を踏まえた上で、もう一度友人にstateについて説明しようと思います。

Discussion