useStateの更新用関数は、なぜ最新の状態を参照できるのか。
はじめに
Reactにおいて最も使われているフックと言っても過言ではないuseStateですが、その中に更新用関数というものがあります。
今回はこの更新用関数を、useStateが内部でどのように動いているのかという観点で深掘ってみました。
前提
下記のようなnumber型のstateがあると仮定し、現在の数値に1を足すという関数を作ってみます。
const [inputNumber, setInputNumber] = useState<number>(0)
下記の場合、2つとも同じ結果になると思います。
// パターン1
const handleChange = () => {
setInputNumber(inputNumber + 1)
}
// パターン2
const handleChange = () => {
setInputNumber((prev) => prev + 1)
}
では、下記の場合はどうでしょうか。
// inputNumber === 0 とする
// パターン1
const handleChange = () => {
setInputNumber(inputNumber + 1)
setInputNumber(inputNumber + 1)
}
// パターン2
const handleChange = () => {
setInputNumber((prev) => prev + 1)
setInputNumber((prev) => prev + 1)
}
結果はパターン1 が inputNumber === 1、パターン2が inputNumber === 2 になります。
一見するとどちらも同じ結果になるように見えますが、なぜ違うのでしょうか。
理由は、
setStateで更新した値が実際のstateに反映されるのは、次回のレンダリング時だからです。
そのため、パターン1 で参照しているinputNumberの状態は、handleChangeの処理が全て終わるまで古いままなのです。
それに対し、パターン2 で参照しているprevは常に最新の状態を取得できます。
実際の動きを見てみるとわかりやすいです。
// パターン1
const handleChange = () => {
setInputNumber(inputNumber + 1) // setInputNumber(0 + 1)
setInputNumber(inputNumber + 1) // setInputNumber(0 + 1)
}
// パターン2
const handleChange = () => {
setInputNumber((prev) => prev + 1) // setInputNumber((0) => 0 + 1)
setInputNumber((prev) => prev + 1) // setInputNumber((1) => 1 + 1)
}
ここまでが前提です。
普段Reactを書いている方からすると 「それくらい知ってるよ!」 という内容だったかもしれません。
本題
ではなぜ、パターン2のような更新用関数を使うと最新の状態を参照できるのでしょうか。
setStateを実行した際、Reactが内部でどんなことをしているのか見てみましょう。
実際の処理
setStateを実行した時の処理は大きく分けて準備フェーズと更新フェーズの二つに分かれます。
まずは、準備フェーズから見ていきましょう。
準備フェーズ
かなり省略していますが、こちらがsetStateを実行した時の内部的な処理です。
なんのこっちゃと言う感じですが、concurrentQueuesという配列に色々入れているみたいです。
公式ドキュメントの、Reactはstate 更新をまとめて処理するにもありますが、
ReactはsetStateが受け取った処理をすぐには実行せず、一旦キューの中に入れ、次回レンダリング時にまとめて実行するということをしています。
そのキューに入れるという処理をしているのがここです。
省略しますが、キューに入った処理たちは、最終的に一つのオブジェクトに格納されます。
といっても文章だけだと伝わりづらいので、Reactのソースコードをデバッグして実際の値を出力してみました。
下記のようなstateと、あいうえおが作れそうな関数があると仮定します。
const [text, setText] = React.useState<string>('あ');
const handleChangeText = () => {
setText(text + 'い');
setText(prev => prev + 'う');
setText(text + 'え');
setText(prev => prev + 'お');
};
handleChangeTextが実行されると、Reactの内部で下記のようなupdateオブジェクトが作成されました。
update: {
// 1つ目のsetState処理
action: "あい",
next: {
// 2つ目のsetState処理
action: (prev) => {
return prev + 'う'
},
next: {
// 3つ目のsetState処理
action: "あえ",
next: {
// 4つ目のsetState処理
action: (prev) => {
return prev + 'お'
},
next: {...}
},
},
},
};
setStateの処理が連結しているオブジェクトになっています。
setText(text + 'い') のような更新用関数を使わない処理は、既に計算された状態で入っているんですね〜
長くなりましたが、ここまででやっと準備完了です。
このupdateオブジェクトを使い、いよいよ実際の更新処理に入ります。
更新フェーズ
更新フェーズのメイン処理は二つになります。
一つ目がbasicStateReducerという関数です。こちらが実際にsetStateの処理を実行している箇所になります。
確かに、すごくsetState感のある処理です。
最後に下記が、このbasicStateReducerを使いながらstateを更新している処理になります(ここではreducerという名前で呼ばれています)
解説すると、こちらはループを回している中での処理になります。
先ほどのupdateオブジェクトに入ったsetStateの処理たち(ここでいうaction)を上から順番に実行していき、newStateという変数に入れていくという内容です。
メインは1529行目になります!
せっかくなのでこちらも実際の動きを見てみましょう。
実行予定のアクションと、実行前後のnewStateの状態をそれぞれ確認してみました。
// stateと処理の内容
const [text, setText] = React.useState<string>('あ');
const handleChangeText = () => {
setText(text + 'い');
setText(prev => prev + 'う');
setText(text + 'え');
setText(prev => prev + 'お');
};
// 仕込んだログ
console.log('action: ', action)
console.log('prev: ', newState);
newState = reducer(newState, action); // ここで更新
console.log('new: ', newState);
// 出力結果
// 1周目
action: あい
prev: あ
new: あい
// 2周目
action: (prev) => prev + 'う'
prev: あい
new: あいう
// 3周目
action: あえ
prev: あいう
new: あえ
// 4周目
action: (prev) => prev + 'お'
prev: あえ
new: あえお
なるほど〜〜
ループのたびにnewStateを最新の値に書き換え、更新用関数ではそれを使うという流れなんですね。
ちなみにこのnewStateは、最終的にuseState()の返り値としても使われている値になります。
というわけで最終的な値は、あいうえおではなく、あえおになりました。残念😢
感想
普段何気なく書いているReactの中を覗いてみましたが、とても壮大な実装に圧倒されました...。
特にグローバル変数の多さには頭を抱えました💧
ただ、そんな壮大なコードを読み進めていく中で、直感的でわかりやすい命名と丁寧なコメントがいかに大切かを改めて実感しました。
普段の開発でも、読み手にとって親切でわかりやすいコーディングを心がけたいと思いました!
参考
Discussion