【Key-front】State as a Snapshot / Queueing a Series of State Updatesを読んでみた
モチベーション
- 毎週木曜日Slackのkey_frontチャンネルでハドル機能を使いお題に対してメンバー同士ディスカッションをする時間を15〜30分程度設けている
- 今回は「State as a Snapshot / Queueing a Series of State Updates」についての共有と周辺知識について触れていく
- ファシリテーターは筆者なので、事前に読み込んで気になった点などをスクラップに投げていく
- 開催日は10/12(木)で最終的に議事録として結論をまとめる
State as a Snapshot
- 下記の場合なぜ更新したselectedを取得することができないのか?を掘り下げていく。
- 昔、筆者もReact触りたての頃は同じところで引っかかった記憶がある。
- 言語化まではできていなかったので改めて調査していく。
// XXX: bug here
const onSubmit = ({ id, name }) => {
setSelected({ id, name });
mutate(selected);
};
// ここを修正したら直ります。
const onSubmit = ({ id, name }) => {
setSelected({ id, name });
mutate({ id, name });
};
メモ1
- 下記の記事に「具体例」「誤った解説」「正しい解説」が記載されているので参照する。日本語のState as a Snapshotの記事の中で一番わかりやすいと思う
- 味噌:「関数はその外側にある変数の値をキャプチャするからです。」
メモ2
- 味噌:「クロージャは関数を作成した時点での変数の値と関数を保持しているものなのである。」
const countUp = () => {
return setInterval(() => {
setCount(count + 1);
}, 1000);
};
countUp関数を作成する際にsetInterval関数の中にあるcountはクロージャに捕らえられてしまい、この時点でのcountの値(=0)を保持し続けることになってしまっていたのである。そのため、setIntervalで何度setCountが実行されようと
0+1
を延々と繰り返し、カウントアップできなかったのである。
メモ3
- useStateの簡易的な動き(クロージャーの挙動)の解説と「関数はその外側にある変数の値をキャプチャするからです。」と「クロージャは関数を作成した時点での変数の値と関数を保持しているものなのである。」の辻褄合わせをする。
const useStateReplica = (initialValue) => {
let value = initialValue
const state = () => {
return value
}
const setState = (newValue) => {
value = newValue
}
return [state, setState]
}
const [count, setCount] = useStateReplica(0)
console.log(count()) // logs 0
setCount(1)
console.log(count()) // logs 1
- React の内部で、レンダリングごとに
count()
のようなコードが実行されているからです。- 特定のレンダリングの中では state の値が必ず一つに定まります。
- 何となく下記を参照している気がする
- 補足:もしuseStateの仕組みを知りたい場合は下記を参照する
メモ4
- 味噌:「ステートの更新は、新しい値による新しいレンダーの実行をリクエストする」
それは、ステートがスナップショットのように振る舞うからです。 ステートの更新は、新しい値による新しいレンダーの実行をリクエストするのであって、すでに実行中のイベントハンドラの中に書かれた JavaScript の
count
変数(の値)に影響を与える訳ではありません。
今回登場したクロージャーについては別途取り上げる。
クロージャとは、ある関数がそのレキシカル・スコープ外で実行されているときでも、そのレキシカル・スコープを記憶してアクセスできることである。
疑問1
- メモ4の「ステートの更新は、新しい値による新しいレンダーの実行をリクエストする」かつメモ3の「React の内部で、レンダリングごとにcount()のようなコードが実行されているからです。」なら下記の場合は3回レンダリングされて3になるのではないかと原点回帰してしまうかもしれない
- 上記に関してはQueueing a Series of State Updatesで掘り下げる
const onClickThreeUpMoon = () => {
setLeftMarioCount(leftMarioCount + 1);
setLeftMarioCount(leftMarioCount + 1);
setLeftMarioCount(leftMarioCount + 1);
}
参考記事
- 今更だが、なぜuseStateはconstで宣言できるのか?
Queueing a Series of State Updates
疑問1でも取り上げたように下記のような場合はレンダリングが2回発生するのか?
const onChange = ({ id, name }) => {
const newNames = {
...names,
[id]: name
};
setNames(newNames);
setChangedTimes(prev => prev + 1);
};
結論
Reactはイベントハンドラ内の処理を先に実行して、その後hookの値に変更があれば、新しい値を使って部品を再レンダリングします。 もっと深堀りしたら、複数のhook処理があるならまとめて実行した後再レンダリングします。例えば: onChange処理にはsetNamesとsetChangedTimes2つのhook処理があります。ある入力欄を変えたら1回再レンダリングが行われます。
- 後述するがisuueでも取り上げられている
- 状態を更新しても1回しかレンダリングされないのは
バッチ処理
が原因
バッチ処理されない実装
- promiseから実行されている場合はバッジされない
- 下記の場合は初期レンダリングも合わせて6回レンダリングされる
useEffect(() => {
if (!inited) {
const fetchData = async () => {
setLoading(true);
// 略
setNames(newNames);
setData(data);
setInited(true);
setLoading(false);
}
fetchData();
}
}, [inited]);
- 下記の場合は初期レンダリングも合わせてsetLoadingを合わせて3回レンダリングに抑えることができる
- Reactの公式ドキュメントに記載されてないAPIを使えば強制的にまとめられるのです。
useEffect(() => {
if (!inited) {
const fetchData = async () => {
setLoading(true);
// 略
unstable_batchedUpdates(() => {
setNames(newNames);
setData(data);
setInited(true);
setLoading(false);
});
}
fetchData();
}
}, [inited]);
まとめ
-
unstable_batchedUpdates
はreact内部のものっぽい - React18からはすべての更新はbatch updateになるので「バッチ処理されない実装」に関しては気にしなくてよさそう
参考記事
Reactのバッチ処理が組み込まれた理由
- DOM の更新を一括して行えば、DOM の更新を連続して何度も行うことによるパフォーマンスの問題を軽減することができるから
そこで React チームは、DOM の更新をバッチ処理することにしました。つまり、30 回の DOM 更新が発生するような状態変化があった場合、次々に実行するのではなく、一度にすべて実行するようにしたのです。
DOMのバッチ処理に加えてreconciliationも絡めてパフォーマンスについて触れられていた。compositionパターンもついでに触れる。
このバッチ処理を行うには、DOMの更新に対するオーナーシップを持つ必要があります。そこで、DOMをどのように見せたいかを表すReact.createElement(これがJSXです)を用意し、状態変化があったときに、Reactは再びこの関数を呼び出して必要なReact要素をDOMにレンダリングするようにします。
そして、その新しい React 要素と、前回レンダリングしたときの要素を比較します。これにより、どの DOM を更新すべきかがわかり、可能な限りパフォーマンスの高い方法で更新が行われます。
DOM を更新するプロセスは「コミット」と呼ばれます
。これは、「レンダリング」した React 要素を受け取り、その更新を DOM に「コミット」するためです。
差分検出処理(reconciliation)とcompositionパターン
-
compositionパターンを知る前にまずはReactのコア機能である差分検出処理(リコンシリエーション)について理解する必要がある。
-
正直RSCでも使う大事な概念なのでよく理解する必要がある
まず前提としてReactはstateを更新すると子コンポーネントを再帰的にレンダリングします。しかし、Reactは差分検出処理(reconciliation)を利用してDOMの必要最小限の部分のみしか更新を行いません。
例えば、前回のレンダリング時から**「参照同一性」**を保持するような要素を見つけた場合Reactはコミットを停止します。
特にコンポーネントに渡されたpropsはstateが更新され再レンダリングされたとしてもその「参照同一性」を保持します。
このような背景があるため、コンポジションを用いるとメモ化を用いなくてもパフォーマンス改善が可能になります。
-
compositionパターンはpropsのバケツリレー問題を回避する手段としての説明が多い(気がする)が実際は次の記事や「Reactのバッチ処理が組み込まれた理由」で紹介した記事にもあったようにパフォーマンス問題にフォーカスしているデザインパターンとしても受け取れる
-
compositionパターンは差分検出処理(reconciliation)を理解すれば腑に落ちるはず
一旦まとめる、あとはkey-frontで参加者の反応を見る
まとめ
- stateはレンダリングのスナップショットであり、stateのアップデートはバッチ処理される
- stateはレンダリングのスナップショットなのはクロージャが関係していて「クロージャは関数を作成した時点での変数の値と関数を保持しているから」「関数はその外側にある変数の値をキャプチャするから」
- stateのアップデートはバッチ処理されるのは「DOM の更新を一括して行えば、DOM の更新を連続して何度も行うことによるパフォーマンスの問題を軽減することができるから」
議事録_20231012
- 10/12(木)に実施
意見
- 下記のように非同期処理を間に挟むと2回レンダリングが発生するらしい
- React18でも同じらしい
- 未検証なので後で検証する
const onChange = ({ id, name }) => {
const newNames = {
...names,
[id]: name
};
setNames(newNames);
fetchData() // Promise
setChangedTimes(prev => prev + 1);
};
- 参加人数は7名(以下エビデンス:meet参加者いたため7人だった)
- 次回はReact18について取り上げる予定