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 が 1
、パターン2が 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