useSyncExternalStore を使って Suspense 対応のデータストアを作ってみる
とりあえずサンプルコード:
動作するDemo:
コメント
未検証な点がいくつかあるが、一旦動作については問題なさそう
わかりやすさを重視して、hooksから分離できるはずの機能が一緒になってる
本編
やったことはSuspense対応しているhooksの実装パターンの検証。そしてReact18からConcurrent Modeでの整合性確保のために追加されたuseSyncExternalStore を利用した外部データストアの参照を併用する必要があるので試してみた。
React Suspenseの動きや原理はこちらの記事がとてもわかりやすいので、先に読んでおくとより理解が深まります!著者様に大感謝🙏
useSyncExternalStoreの公式ドキュメントはこちら
簡単に説明すると、外部のデータストアを参照しつつ、そのデータストアの値が変化したときに再描画を走らせたいケースで利用する。
ここで更に考慮が必要なのがReactのConcurrent Modeである。Concurrent ModeにおけるTime Slicingの機能ではコンポーネントの描画が非同期になって再描画待ちの期間は旧ステートで描画されるケースがある。また、useTransitionでもpending中に旧ステートで描画されることになる。そのためuseStateなどではなく外部に保持している値を描画に関与させるためには、Reactが描画に影響を与える値のある時点でのスナップショットを取れる必要がでてくる。そのために生まれたのがuseSyncExternalStoreである。
ナイーブな実装例がこんな感じ。
code:naive.ts
let value: string = "hello";
const listeners: (() => void)[] = [];
const subscribe = (cb: () => void) => {
listeners.push(cb);
return () => {
const index = listeners.indexOf(cb);
if (index !== -1) {
listeners.splice(index, 1);
}
};
};
const triggerUpdate = () => {
for (const cb of listeners) {
cb();
}
};
export const updateValue = (newValue: string) => {
value = newValue;
triggerUpdate();
};
export const useValue = () => useSyncExternalStore(subscribe, () => value);
subscribe(cb) で値の変更通知を受けるcb関数を登録できる。そのsubscribe関数自体をuseSyncExternalStoreに食わせることで、Reactが再描画が必要かどうかを得ることができる。
外部のデータストア(上のサンプルでは let value)を書き換えた際は、登録されているcbをすべて呼び出して全コンポーネントに再描画を要求することで、データと描画の一貫性を達成する。
次は、最初になんらかの値を非同期にfetchするパターン。useQueryとかと同じような挙動だと思ってもらえると。上と同じ部分は省略している。エラーケースも簡単のために省略。
code:async.ts
let value: string | null = null;
let promise: Promise<void> | null = null;
const listeners: (() => void)[] = [];
const subscribe = (cb: () => void) => { ... };
const triggerUpdate = () => { ... };
const updateValue = (newValue: string) => { ... };
const fetchValue = () =>
new Promise<string>((resolve) => {
setTimeout(() => resolve(hello ${new Date().toISOString()}), 500);
});
export const revalidateValue = () => fetchValue().then((v) => updateValue(v));
export const useAsyncValue = () =>
useSyncExternalStore(subscribe, () => {
if (value !== null) {
return value;
}
if (promise !== null) {
throw promise;
}
promise = revalidateValue();
throw promise;
});
useSyncExternalStore の第2引数の値のスナップショット取得処理でキャッシュが存在しない場合には非同期で値を取りに行き、Promiseをthrowしてあげることでコンポーネントをサスペンドする。Promiseが解決されればコンポーネントの描画が再開する。
またpromiseもキャッシュしてあげて、複数コンポーネントでhooksを利用した際に無駄なリクエストを飛ばさないようにしてあげる。
基本的な動きはこれでよさそう。あとはrevalidate時にpending中のPromiseを中断するようにしたり、エラーもthrowしてあげるようにすれば良い。
もう少しいろいろ考慮を増やしたものが以下になる。
作っててまだ未解決な点もある。考察をメモっておく。
初期renderでPromiseをthrowしてpendingし、その後Promiseがresolveする前にそのコンポーネントがツリーからアンマウントされた場合、そのPromiseを中断するにはどうしたらよい?
そのようなライフサイクルはたぶんなさそう。しかし、Promiseをキャッシュしておけば、再度render時に同じキャッシュを参照すれば利用可能にはなるし、無駄ではない。多くのリアリスティックなケースでは問題ではなくなるとは思う。しかし異なるキャッシュキーでPromiseを作りまくってそれらが永遠にresolveされない場合にはメモリリークの原因にはなり得る。
RSCにおける問題点もいくつかある。
現時点ではクライアントサイドに来ないと解決しないsuspendという概念が無い。たとえばlocalstorageが取れるまでsuspendするhooksなどSSRで利用できない。
サーバー側でsuspend起こすとRSC的にはPromiseが解決するまでサーバー側からストリームされてこないままになる。現時点でこれを解決するためには、サーバー側ではSuspense配下をrenderせずにsuspendされる前提でfaillbackのrenderを行なって、クライアントサイドに来てから再描画を行うしか無い。これでいちおうhydrationエラーなどは避けられる。
イメージは以下の様なコードである。
code:ClientSuspense.tsx
export default function ClientSuspense({ children, fallback }: PropsWithChildren<{ fallback?: ReactNode }>) {
useEffect(() => {
setRender(true)
}, [])
if (!render) {
return <>{fallback}</>
}
return <Suspense fallback={fallback}>{children}</Suspense>
}
たとえばこの設計をSSR時には先読みして必ずfallbackされるSuspenseということでClientSuspenseと呼ぶことにする。しかしながら、これはSuspenseの思想的には不自然なものになっていることに気づく。Suspenseというのは内部になにかfallbackし得るものがあるとしたらこのスコープでfallbackさせますよという意味だけを宣言するものだったが、ClientSuspenseはその内部を実行せずにfallbackすることを先読みして確定させてしまっている。これは微妙な感じだ。
じゃあ実行してSSR時はエラーにしてErrorBoundaryで拾いつつfallbackさせるか...などと考えたりしたのだがErrorBoundaryはSSRでは動作しない。現状では最適解はなさそうである。
そして体験的に見るとClientSuspenseの実装はflashingを起こしてしまうため、避けたほうが良いのかもしれない。もしスケルトンを表示できるような非同期的な時間差があるのであれば良いが、localstorageを読みに行くなど同期的に完了してしまうような時間差で再描画を行うのはあまりよろしくない。ClinetSuspenseなど複雑なことはせずに、クライアントサイドの情報に関わらず常に一意なrenderを行い、ユーザーのインタラクションなどをきっかけにして割り込みモーダルを出したりなどを行なったほうがよりシンプルでかつ体験的にも自然になるのではないかと考えた。
まだまだNextやReactの実装はunstableであるので、なにかupdateがあったら追記していきたい。