useState のステート変更の反映の流れ改めて整理した
前提
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関数の引数には何を渡すべきなのか
個人的には、以下のように考えます。
- 基本的に、更新関数の引数には更新したい値を渡す
値を渡すようにすることで、Reactの効率的なレンダリングと状態の更新の動きに則った処理がされます。
setCounter(counter+1)
- 同じイベントハンドラ内などで更新後の値を使用したい場合には引数に状態更新用の関数を渡す
ただしReactの効率的なレンダリングや状態の更新に反する形にもなるかと思うので、どうしてもというケースでない限りは使用しないことが良いのかなと思います。
setCounter((prevCounter)=> prevCounter+1)
どっちが正しいでどっちが間違い、というよりも、結果「場合に応じて使い分けよう」で良いのでは、という当たり障りのない感じの結論です。
Discussion