📚

Reactは状態のスナップショットをレンダリングする

2023/02/10に公開

Reactの公式ドキュメントを眺めていると時折 snapshot(スナップショット) という単語が登場します。State as a Snapshotでは、state behaves more like a snapshot(状態はスナップショットのように振る舞う) と記述がありますが、初めて読んだ時は少し理解に時間がかかりました。本記事ではこのスナップショットという言葉について考えてみようと思います。

本記事の対象者

  • Reactの初学者
  • State as a Snapshotを読んだことない / 読んだことはあるが理解しきれていない人
  • 本記事のタイトルの意味がよくわからない人

本記事の目的

  • Reactにおけるレンダリングとは何かを理解する
  • スナップショットとしての状態とは何かを理解する
  • レンダリングと状態の関係について把握する

結論

  • Reactにおけるレンダリングとは、コンポーネントという名の関数を呼び出すこと
  • 同一のレンダリングでは状態が固定される
  • レンダリングでは状態のスナップショットを参照している

Reactにおけるレンダリングとは

本記事のタイトルである「Reactは状態のスナップショットをレンダリングする」という言葉を紐解くためには、レンダリングとスナップショットという2つの単語について考える必要があります。一旦スナップショットについては置いておくとして、まずはレンダリングの意味から考えてみましょう。

“Rendering” is React calling your components.
出典: Render and Commit • React - Step 2: React renders your components

ドキュメントにもレンダリングという単語に対する言及はいくつかありますが、上記によるとReactにおけるレンダリングはコンポーネントの呼び出しのことを指しています。では次に疑問となるのはコンポーネントとは何か、ということでしょう。

a React component is a JavaScript function that you can sprinkle with markup.
出典: Your First Component • React - Defining a component

ここでいうReactコンポーネントはJavaScriptの関数のことです。つまりReactのレンダリングとはコンポーネントという名のJavaScriptの関数を呼び出すことを意味します(この結果としてJSXが返ってきます)。

勘違いしがちですが、Reactのレンダリングはブラウザの描画とは異なることに気を付けてください。Reactでは、これらを区別するためにブラウザの描画のことをペイントと呼んでいます。次にレンダリングとペイントの流れを簡単に確認してみましょう。

コンポーネントがブラウザに表示されるまで

1.Triggering a render (delivering the guest’s order to the kitchen)
2.Rendering the component (preparing the order in the kitchen)
3.Committing to the DOM (placing the order on the table)
出典: Your First Component • React

ドキュメントに書かれているように、Reactがコンポーネントをブラウザに表示するまでには、次の3つの手順を踏みます。

  1. レンダリングのトリガー
  2. コンポーネントのレンダリング
  3. DOMへのコミット

上記の3つの手順を踏んだ後、最終的にブラウザに描画(ペイント)されることになります。

1.レンダリングのトリガー

レンダリングのトリガーには次の2つが存在します。

  • 初期レンダリング
  • コンポーネント(またはその祖先コンポーネント)の状態更新

初期レンダリング

初期レンダリングとはアプリの起動時にトリガーされるものです。createRootとそのrenderメソッドを呼び出すことで、ブラウザーのDOM要素内にコンテンツを表示するためのReactルートを作成します。

import { createRoot } from 'react-dom/client';
import App from './App.js';

const root = createRoot(document.getElementById('root'));
root.render(<App />);

コンポーネント(またはその祖先コンポーネント)の状態更新

初期レンダリングの後は、set関数で状態を更新することにより、次のレンダリングをトリガーできます。

const Counter = () => {
  const [count, setCount] = useState(0);
  // setCountで状態を更新することでレンダリングをトリガーする
  return <button onClick={() => setCount(count + 1)}>count: {count}</button>;
};

上記のコードではボタンをクリックするたびにsetCountが実行され、次のレンダリングをトリガーしています。

2.コンポーネントのレンダリング

レンダリングがトリガーされると、次にReactはレンダリングを行います(つまりコンポーネントを呼び出します)。初期レンダリングでは、Reactはルートコンポーネントを呼び出し、再レンダリングでは、Reactは状態更新が生じた関数コンポーネントを呼び出します。

再帰的なレンダリングプロセス

このレンダリングプロセスは再帰的です。レンダリングが生じたコンポーネントが他の子コンポーネントを含むとき、その子コンポーネントも同様にレンダリングされます。このレンダリングの連鎖はネストされた子コンポーネントがなくなるまで続きます。

これは次のコードでParentComponentが再レンダリングされるとChildComponentも再レンダリングされるということです。

// ParentComponentが再レンダリングされるとChildComponentも再レンダリングされる
const ChildComponent = () => {
  return <p>ChildComponent</p>;
};

const ParentComponent = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <ChildComponent />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
    </div>
  );
};

3.DOMへのコミット

コンポーネントのレンダリングを終えると、ReactはDOMを変更します。初期レンダリングでは、appendChild()を使用して、作成したすべてのDOMノードを追加します。再レンダリングでは、DOMを最新のレンダリングの出力結果と一致するように変更します。この時、前回のレンダリング結果との間で差異があったDOMノードのみを変更します。

レンダリング間で差異があったDOMノードのみを変更する

レンダリング間で差異があったDOMノードのみを変更するとはどういうことでしょうか。たとえば以下のようなコードがあったとします。

const Counter = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      {/* buttonのクリックごとにcountが更新される */}
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        count: {count}
      </button>
      {/* buttonをクリックしてもinputのテキストはリセットされない */}
      <input type="text" />
    </>
  );
};

上記ではbuttonをクリックするとCounterコンポーネントが再レンダリングされます。その結果buttonのテキストはクリックごとに変更されますが、inputに入力した文字はリセットされません。これは一体なぜでしょうか。

ここでレンダリングという言葉の意味を思い出してください。レンダリングとはコンポーネントという名のJavaScriptの関数を呼び出すことでした。関数を呼び出した結果、JSXを取得するのですが、よくよく見返してみるとJSX上のinput(とそのvalue)はレンダリング間で同等です。したがってReactはinputに変更を加えません。

ではbuttonのクリックごとにinputのDOMノードも変更したい(inputをリセットしたい)時はどうすればいいでしょうか。Reactではkey属性を使ってこの問題を解決することができます。

const Counter = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      {/* buttonのクリックごとにcountが更新される */}
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        count: {count}
      </button>
      {/* keyが変更されることでinputがリセットされる */}
      <input type="text" key={count}/>
    </>
  );
};

Reactは状態のスナップショットをレンダリングする

レンダリングについて確認したところで、次はスナップショットについて考えてみます。スナップショットという単語は、さまざまな分野で使われているようですが、ある時点のデータをそのまま保存したモノという意味があるようです。これをReactに当てはめてみると、「ある時点」は「あるレンダリングの時点」に、「データ」は「状態」に置き換えて考えることができます。

レンダリングとはコンポーネントを呼び出すこと(呼び出してJSXを取得すること)でした。状態のスナップショットをレンダリングするということは、すなわちコンポーネントが呼び出された時点の状態をもとに計算されるということです。これは同一のレンダリング内において、状態は固定されることを意味します。

たとえば次のようなコードでその挙動を確認することができます。

const Counter = () => {
  const [count, setCount] = useState(0)
  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }
  return (
    <button onClick={handleClick}>count: {count}</button>
  )
}

上記では、1度のbuttonのクリックにつきcountが1ずつ増加します(3ずつは増加しません)。以下は初回のbuttonクリック時の動きを可視化したものになります。

// countは常に0
const handleClick = () => {
  setCount(0 + 1);
  setCount(0 + 1);
  setCount(0 + 1);
}

React waits until all code in the event handlers has run before processing your state updates.
出典: Queueing a Series of State Updates • React - React batches state updates

Reactはイベントハンドラーの全てのコードを実行するまで待機してから、状態を更新することに気を付けてください。同一のレンダリング内において、状態は固定されるので、上記では状態が常に同じ値となります。

では次のコードではどうでしょうか。

const Counter = () => {
  const [count, setCount] = useState(0)
  const handleClick = () => {
    setCount(count + 5);
    setTimeout(()=>{
      alert(count)
    }, 3000)
  }
  return (
    <button onClick={handleClick}>count: {count}</button>
  )
}

上記のコードでbuttonを1度だけクリックすると、button内のcountはすぐ5になるにもかかわらず、alert(count)で表示される値は0であることに気がつくでしょう。これはalert(count)に対して、状態のスナップショットが渡されているということを示しています。イベントハンドラー内のコードがたとえ非同期処理であったとしても、状態はレンダリング時点に固定されることを忘れてはいけません。

同一レンダリング内で同じ状態を複数回更新する

It is an uncommon use case, but if you would like to update the same state variable multiple times before the next render, instead of passing the next state value like setNumber(number + 1), you can pass a function that calculates the next state based on the previous one in the queue, like setNumber(n => n + 1). It is a way to tell React to “do something with the state value” instead of just replacing it.
出典: Updating the same state variable multiple times before the next render - Queueing a Series of State Updates • React

ドキュメントには It is an uncommon use case(稀な使用例) とは書かれていますが、Reactでは次のレンダリングを待たずに同じ状態を複数回更新する方法も用意されています。

const Counter = () => {
  const [count, setCount] = useState(0)
  const handleClick = () => {
-    setCount(count + 1);
-    setCount(count + 1);
-    setCount(count + 1);
+    setCount(c => c + 1);
+    setCount(c => c + 1);
+    setCount(c => c + 1);
  }
  return (
    <button onClick={handleClick}>count: {count}</button>
  )
}

上記のようにコードを書き換えてbuttonをクリックするとcountが3ずつ増加します。ドキュメントではc => c + 1の部分を更新関数と呼んでいますが、この更新関数をset関数に渡すことで、同一レンダリング内でも、前の状態に基づいた状態の更新をすることができます。これは状態のスナップショットから計算した値をset関数に渡す代わりに、状態に依存しない更新関数を渡すことでReactに「状態値をどのように変換するか」を指示する方法です。

おわりに

本記事では snapshot(スナップショット) という単語に着目しつつ、Reactのレンダリングと状態についてまとめてみました。React Docsでは単にReactの使い方を知るというだけではなく、そのメンタルモデルについても学べることが多くあります。まだ読んだことがない方は是非一読してみてください。

参考

https://react.dev/learn/render-and-commit
https://react.dev/learn/state-as-a-snapshot
https://react.dev/learn/queueing-a-series-of-state-updates

Discussion