useSyncExternalStore の挙動への誤解
useSyncExternalStore
の挙動を誤解していたために外部ストレージをうまく扱えていなかったので、覚書がてら簡潔に。
言いたいこと
-
useSyncExternalStore(subscribe, getSnapshot)
はgetSnapshot
が新しいインスタンスを返すときのみ更新され、getSnapshot
はsubscribe
関数内で購読しているイベントが発火されたタイミングで発火される。 - すなわち、
subscribe
関数内に再レンダーさせたいタイミングのイベントを記述するだけでは再レンダーされない場合があることに注意。
useSyncExternalStore
の用途・使用方法
- 外部ストレージのカスタムフック作成を補助する React のフック (v18^)
- 同様のことは今までも
useState
とuseEffect
を用いてできていたが、これによりシンプルに書けるようになった
// https://ja.react.dev/reference/react/useSyncExternalStore#subscribing-to-a-browser-api
import { useSyncExternalStore } from "react";
// オンライン状態かどうかを返すカスタムフック
export function useIsOnline() {
return useSyncExternalStore(subscribe, getSnapshot);
}
// オンライン状態を返すだけの関数
function getSnapshot() {
// navigator.onLine: ブラウザーがオンラインかどうかを返す
return navigator.onLine;
}
// オンライン・オフラインの変化イベントを購読するための関数
// 購読を解除する関数を返す必要がある
function subscribe(callback) {
addEventListener("online", callback);
addEventListener("offline", callback);
return () => {
removeEventListener("online", callback);
removeEventListener("offline", callback);
};
}
経緯
先の例で以下のように、navigator.connection
のようなインスタンスも一緒に返したいとする:
function getSnapshot(): [boolean, Geolocation] {
return [navigator.onLine, navigator.geolocation];
}
これは以下のようなエラーが出る:
useIsOnline.ts:4 Warning: The result of getSnapshot should be cached to avoid an infinite loop Error Component Stack
at App (App.tsx:9:29)
これを回避するために snapshot をキャッシュするように変更する(参考):
const cache: [boolean, Geolocation] = [navigator.onLine, navigator.geolocation];
function getSnapshot() {
cache[0] = navigator.onLine;
cache[1] = navigator.geolocation;
return cache;
}
こうすると今度は online/offline イベント発火時に useSyncExternalStore
が変更された値を返さなくなる。
原因
useSyncExternalStore
に渡す subscribe
関数が引数に受け取る callback は以下の様になっている:
function subscribeToStore(fiber, inst, subscribe) {
var handleStoreChange = function () {
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
}; // Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
}
「購読しているイベントが発火したタイミングで、スナップショットが変更されていればレンダリングを強制する」という文脈が読み取れる。
先のキャッシュ化の実装はスナップショットが同じオブジェクトを返してしまうので、購読したイベントが発火してもスナップショットが変更されていないため、useSyncExternalStore
が新しい値を返さず、再レンダリングも発生しない。
解決策の例
navigator.onLine
が変更された際の navigator.geolocation
が欲しいだけなら そもそもあんなカスタムフックを作る必要はない。
export function useIsOnline() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return [isOnline, navigator.geolocation];
}
function getSnapshot() {
return navigator.onLine;
}
function subscribe(callback: () => void) {
addEventListener("online", callback);
addEventListener("offline", callback);
return () => {
removeEventListener("online", callback);
removeEventListener("offline", callback);
};
}
なんにせよ、自分が作成したいフックの更新条件を整理する必要があることに注意。
useReplicant を作成する例
以下ほぼおまけ
この記事は NodeCG の useReplicant
を自作した際に困ったための備忘録という面があるため、以下に実装と要点を記述する。
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { IReplicant } from "@/types/schemas";
const defaultValue: IReplicant = { ... };
type SetValueAction<T> = (newValue: T | ((old?: T) => T)) => void;
type ReturnType<TKey extends keyof IReplicant> = [
IReplicant[TKey] | undefined,
SetValueAction<IReplicant[TKey]>,
{
isLoaded: boolean;
}
];
const DUMMY = Symbol();
export const useReplicant = <TKey extends keyof IReplicant>(
key: TKey
): ReturnType<TKey> => {
// replicant 自体はリアクティブでなくて良い
const replicant = useMemo(() => {
return nodecg.Replicant<IReplicant[TKey]>(key, {
defaultValue: defaultValue[key],
});
}, [key]);
// replicant.status === "declared" になると DUMMY ではなく replicant.value を返すようになり、
// getSnapshot() の返すオブジェクトが変更された判定になる
// -> 再レンダリングが走る
//
// DUMMY ではなく undefined だと、 replicant.value がもともと undefined だった場合に
// getSnapshot() の返すオブジェクトが undefined のまま
// -> 再レンダリングが走らない
const getSnapshot = () => {
return replicant.status === "declared" ? replicant.value : DUMMY;
};
const subscribe = (callback: () => void) => {
replicant.on("change", callback);
return () => {
replicant.removeListener("change", callback);
};
};
// DUMMY をわざわざ採用している弊害が出ている
const storedValue =
useSyncExternalStore(subscribe, getSnapshot) === DUMMY
? undefined
: replicant.value;
const isLoaded = replicant.status === "declared";
const setValue: SetValueAction<IReplicant[TKey]> = (newValue) => {
if (typeof newValue === "function") {
replicant.value = newValue(storedValue);
} else {
replicant.value = newValue;
}
};
return [storedValue, setValue, { isLoaded }];
};
大事な点はコメントに記述しているが、
-
replicant.status === "declared"
になるとDUMMY
ではなくreplicant.value
を返すようになり、getSnapshot()
の返すオブジェクトが変更された判定になる- -> 再レンダリングが走る
-
DUMMY
ではなくundefined
だと、replicant.value
がもともとundefined
だった場合にgetSnapshot()
の返すオブジェクトがundefined
のまま- -> 再レンダリングが走らない
-
const storedValue = ...
でDUMMY
をわざわざ採用している弊害が出ている- 回りくどいだけではある
useEffect
と useState
の組み合わせより記述量が増えるが、比較的ロジックはシンプルに書けてわかりやすい(多分)
追記
Replicant.revision
を使っても良い気がした
-
Replicant.revision
は、データの版がインクリメンタルに記録される変数 -
replicant.status === "declared"
or 初期状態は0
const getRevision = () => {
return replicant.status === "declared" ? replicant.revision : undefined;
};
const subscribe = (callback: () => void) => {
replicant.addListener("change", callback);
return () => {
replicant.removeListener("change", callback);
};
};
const revision = useSyncExternalStore(subscribe, getRevision);
const storedValue = revision !== undefined ? replicant.value : undefined;
const isLoaded = revision !== undefined;
Discussion