200行のストア
目標
Redux・Zustand・Jotaiが共通して解いている問題を、自分で解いてみます。 ファイル1つで、200行に収まります。
作るもの
/root/work/state/store.mjsからcreateStoreをexportします。
export function createStore(initial) {
return { getState, dispatch, subscribe, select }
}
| 関数 | 契約 |
|---|---|
getState() |
現在の状態をすぐに返します |
dispatch(fn) |
fn(현재)の戻り値が新しい状態になります(プレースホルダーは現在の状態です)。元の状態は変更しません |
subscribe(l) |
解除関数を返します |
select(sel) |
sel(현재)を返します(プレースホルダーは現在の状態です)。状態が変わっていなければ再計算しません |
採点方法
採点ツールが皆さんのstore.mjsをimportして、契約を1つずつ確認します。
ブラウザもフレームワークも使いません。
node --version # v22
node store.mjs # 문법 확인용 (아무것도 출력 안 해도 됩니다)
ステップ
getState・dispatch- その場での変更の禁止
subscribe→ 解除関数- 同じ参照なら通知しない
select: 派生は計算する- メモ化
- マイクロタスクによるバッチ処理
- 走査中の解除に対する安全性
- まとめ →
09-notes.md
参考
- npm installは使えません。ネットワークがなく、必要でもありません。
- 状態の形は自由です。採点ツールは
{ n: 0, items: [] }のような平凡な オブジェクトを渡して、契約だけを確認します。
ストアの骨組みを作る
/root/work/state/store.mjsからcreateStore(initial)をexportしてください。返すオブジェクトにはgetState()とdispatch(fn)が必要で、dispatchはfn(현재상태)の戻り値を新しい状態にします(プレースホルダーは現在の状態です)。
mkdir -p /root/work/stateを実行します。ESMなのでexport function createStore(initial) { ... }と書きます。拡張子が.mjsなので、package.jsonは不要です。dispatchの中で状態をその場で変更しないでください。次のステップがそこを確認します。
その場での変更を防ぐ
dispatchが受け取った状態オブジェクトを決して変更しないようにしてください。採点ツールがdispatchの前後で元のオブジェクトを比較します。
Object.freezeで自分自身を守るのも良い方法です。うっかり変更したときに、黙って無視されるのではなくエラーになります。状態の更新は、常に新しいオブジェクト({...s, n: s.n+1})で行ってください。
購読と解除
subscribe(listener)を追加し、解除関数を返してください。解除したあとに再び呼ばれてはいけません。
戻り値は関数でなければなりません。解除は何回呼んでも安全である必要があります(2回呼ぶと別のリスナーが消える実装がよくある落とし穴です)。
変わっていなければ通知しない
dispatch(s => s)のように同じ参照が返ってきたら、リスナーを呼ばないようにしてください。
if (next === prev) returnの1行です。これがないと、状態は同じなのにレンダリングが走り、そのレンダリングが再びdispatchすると無限ループになります。
派生状態は計算する
select(selector)を追加してください。store.select(s => s.items.length)が常に現在の状態を基準にして値を返す必要があります。派生値を状態に保存してはいけません。
状態にcountのようなフィールドを入れておきたくなりますが、そうすると2つがずれる瞬間が来ます。採点ツールは、itemsだけを変更したあとに派生値が追従するかどうかを確認します。
同じ入力なら再計算しない
selectにメモ化を入れてください。状態が変わっていないのに同じselectorで何度も問い合わせたとき、selector関数が1回だけ実行されるようにします。
最後の状態参照と最後の結果を、selectorごとに記憶します(MapやWeakMap)。ここで不変性が価値を発揮します。状態が不変なので、参照比較1つで「変わっていない」とわかります。
1ティックで3回変更してもレンダリングは1回
連続したdispatchを1つのマイクロタスクにまとめてください。dispatch(a); dispatch(b); dispatch(c)のあと、リスナーはちょうど1回だけ呼ばれ、そのときの状態は3回分がすべて反映された最終値でなければなりません。
queueMicrotaskとscheduledフラグが1つあれば足ります。React 18のautomatic batchingがこれです。注意点として、getState()はバッチ処理とは無関係にすぐ最新の値を返す必要があります。dispatch自体を遅らせてはいけません。
走査中に解除しても安全にする
リスナーの中で購読を解除しても、ほかのリスナーが飛ばされないようにしてください。
[...listeners].forEach(...)のように、コピーを走査します。1行ですが、これがないと「たまに1つだけ呼ばれない」という、再現率が低く原因を探しにくいバグになります。
ライブラリがなぜあの形をしているのか
09-notes.mdに3行以上書いてください。不変性が何をO(1)にしたのか、派生状態を保存すると何がずれるのか、バッチ処理がないと何が無駄になるのかを書きます。
本文に불변・파생・배치を含めてください(順に、韓国語で「不変」「派生」「バッチ」を意味する語です)。この3つは、Redux・Zustand・Vueがそれぞれ違う形をしていても、同じように解いている問題です。