🔖

useState のステート変更の反映の流れ改めて整理した

2023/04/30に公開

前提

React の useState を使用している中で、同じプロジェクトのメンバーから「ステートの更新が反映されない」「更新の挙動がよくわからない」という話が出たので、あらためて挙動の確認をしました。
動作確認は CodePen でもできるのでどうぞ。

この記事を読まずとも、Reactのドキュメントを読めばよりちゃんと書いてあります。
Reactのコードも読みながら、、と思いましたが、Fiber周りがしんどすぎて断念しました()

ベースとなるコードは以下です。

function App() {
  const [counter, setCounter] = React.useState(0);
  
  function clickHandler() {
    setCounter(counter+1)
    console.log(counter)
  }
  
  return (
    <div>
      <div>Counter: {counter}</div>
      <button onClick={clickHandler}>Click!</button>
    </div>
  );
}

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

下記のようなコードの場合、 console.log で出力されるのは 0 になります。
ブラウザで出力されるのは更新が反映された 1 です。

なぜコンソールに 0 が出力されるのか

Reactが効率的なレンダリングと状態の更新を行うために、状態の更新をバッチ処理されます。

React batches state updates. It updates the screen after all the event handlers have run and have called their set functions. This prevents multiple re-renders during a single event. In the rare case that you need to force React to update the screen earlier, for example to access the DOM, you can use flushSync.

そのため、上記コードの console.log では更新処理が実行される前の counter の値である 0 が表示されます。

バッチ処理されるタイミング

Reactでバッチ処理が行われるタイミングは以下の2つです。

Reactのイベントハンドラ内

Reactで扱われるイベントハンドラ(例:onClick, onChangeなど)での状態変更は、自動的にバッチ処理されます。
<button onClick={clickHandler} />での処理などバッチ処理対象になります。

ReactDOM.unstable_batchedUpdates

ReactDOM.unstable_batchedUpdatesを使用することで、非Reactイベントハンドラや非同期処理の中で複数の状態変更をバッチ処理できます。
基本的に使うことはなさそうに思います。

参照: Do you know unstable_batchedUpdates in React ? (enforce batching state update)

挙動の確認

上記で挙げたコードを以下のように編集します。
以下のコードではコンソールに 0 が表示され、ブラウザに表示されるのは 10 になります。

function App() {
  const [counter, setCounter] = React.useState(0);
  
  function clickHandler() {
    setCounter(counter+1)
    setCounter(counter+10)
    console.log(counter)
  }
  
  return (
    <div>
      <div>Counter: {counter}</div>
      <button onClick={clickHandler}>Click!</button>
    </div>
  );
}

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

clickHandler 関数は onClick のイベントハンドラで実行されているので、バッチ処理の対象になります。
関数内で呼び出されている counter は関数の実行時にキャプチャされており、その時点での値が関数内で呼び出されます。
counter の値は1つ目の setCounter でも2つ目の setCounter でも同じ値になります。

1つ目の setCounter では setCounter(counter + 1) が実行され、スケジューラにセットされます。
2つ目の setCounter では setCounter(counter + 10) が実行され、スケジューラにセットされます。

この関数内では setCounter は2回実行されていますが、バッチ処理で最後に実行されたsetCounter(counter + 10) が実行され、 counter にはその値がセットされます。

同じイベントハンドラ内で値の更新を反映したい場合

Reactの効率的な処理のためには、React のバッチ処理の仕組みに乗るのが良いのかなと思います。

一方で、同じイベントハンドラ内で更新を反映した値を使用したいケースもあるかと思います。
その場合には、更新関数に状態変更関数に前の状態を引数として受け取る関数を渡します。

function App() {
  const [counter, setCounter] = React.useState(0);
  
  function clickHandler() {
    setCounter((prevCounter)=> prevCounter+1)
    setCounter((prevCounter)=> prevCounter+10)
    console.log(counter)
  }
  
  return (
    <div>
      <div>Counter: {counter}</div>
      <button onClick={clickHandler}>Click!</button>
    </div>
  );
}

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

上記のような修正をした場合、 counter の値として出力されるのは +11 された値になります。
関数形式の更新を使用することで、前の状態を基に新しい状態を計算するため、状態が最新のものに基づいて計算されます。

set関数の引数には何を渡すべきなのか

個人的には、以下のように考えます。

  1. 基本的に、更新関数の引数には更新したい値を渡す
    値を渡すようにすることで、Reactの効率的なレンダリングと状態の更新の動きに則った処理がされます。
setCounter(counter+1)
  1. 同じイベントハンドラ内などで更新後の値を使用したい場合には引数に状態更新用の関数を渡す
    ただしReactの効率的なレンダリングや状態の更新に反する形にもなるかと思うので、どうしてもというケースでない限りは使用しないことが良いのかなと思います。
setCounter((prevCounter)=> prevCounter+1)

どっちが正しいでどっちが間違い、というよりも、結果「場合に応じて使い分けよう」で良いのでは、という当たり障りのない感じの結論です。

参考

Discussion