useReducer + useRef ミラーを useSyncExternalStore で置き換える
TL;DR
- async command(await 後)や外部の listen callback が「今の state」を同期的に読みたいとき、
useReducerの state は closure に閉じて await を跨ぐと古くなる。 - 対策として
latestStateRef.currentに手動ミラーしてdispatchSyncで二重更新する運用は、真実が 2 箇所に分かれて規律頼みになり脆い。 - state の本体を React の外に素の TS オブジェクト(store)として置き、
getState/dispatch/subscribeを提供、React 側はuseSyncExternalStoreで購読するだけにすると、ref ミラーも dispatchSync も二重 reducer も不要になる。
1. 問題
async command(例: Tauri invoke の await 後)や Tauri の listen callback が「今の state」を同期的に読みたい。
React の useReducer が返す state は closure に閉じるので、await を跨ぐと古い値になる。
async function runCommand() {
await invoke("do_something");
// ここの state は await 前の closure に閉じた古い state
}
そこで latestStateRef.current に最新 state を手動でミラーし、dispatchSync で「reducer 更新 + ref 更新」を二重に走らせて凌いでいた。
2. 原因
真実が 2 箇所(React state と latestStateRef)に存在し、両者の同期は「全 dispatch が必ず dispatchSync を通る」という規律でしか守られない。
- どこか 1 箇所でも生の
dispatchを呼ぶと、ref が更新されず黙って壊れる。 dispatchSyncは reducer を呼んで新 state を得て ref にも書くため、reducer が毎 action 2 回走る。
規律に依存する設計は、レビューや将来の変更で簡単に破れる。
3. 解決
state の本体を React の外に、素の TS オブジェクト(store)として置く。
function createProjectStore(initial) {
let state = initial;
const listeners = new Set<() => void>();
return {
getState: () => state,
dispatch: (action) => {
state = reducer(state, action);
listeners.forEach((l) => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
React 側は useSyncExternalStore で購読するだけ。
const state = useSyncExternalStore(store.subscribe, store.getState);
- async command や listen callback は
store.getState()を呼べば常に最新を同期的に読める(closure に閉じない)。 - 真実は store の
state1 箇所だけ。ref ミラーは不要。 dispatchは 1 回だけ reducer を呼ぶ。二重更新も消える。dispatchSyncという特別な dispatch を通す規律そのものが不要になる。
4. 判断のポイント
- 「await や外部 callback から最新 state を同期的に読みたい」という要求が出たら、それは state が React の closure に閉じていることが本質的なミスマッチのサイン。
- ref ミラー + 専用 dispatch で凌ぐと「真実が 2 箇所 + 規律頼み」になる。真実を React の外の store 1 箇所に集約し、
useSyncExternalStoreで React に流し込む方が壊れにくい。 - store は React 非依存の素の TS なので、テストも React 抜きで書ける副産物がある。