useSyncExternalStore は、Reactの外にあるデータをコンポーネントに購読させ、そのデータが変わるたびに再レンダリングします。渡すのは2つの関数です。変更をどう監視するかをReactに伝える subscribe と、現在の値を返す getSnapshot です。
2つの Display コンポーネントはpropsもコンテキストも共有していないのに、どちらも同じストアを購読しているので一緒に更新されます。ボタンが呼ぶのはReactのセッターではなく普通の関数です。3つ目の <Display name="Sidebar" /> を追加すると、ほかに何も変えずに加わります。
構文
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
subscribe(callback)は監視を始め、データが変わったかもしれないときにcallbackを呼び、購読を解除する関数を返します。Reactはコンポーネントのマウント後にそれを呼び、アンマウント時に返された関数を呼びます。getSnapshot()は現在の値を返します。Reactは毎回のレンダリング中と各通知の後にそれを呼び、結果を前回のものとObject.isで比べます。同じ値なら、レンダリングしません。getServerSnapshot()(省略可能)は、サーバーとハイドレーション中に使う値を返します。
subscribe はコンポーネントの外で定義するか、useCallback で安定させてください。レンダリングのたびに新しい subscribe 関数を渡すと、Reactは毎回購読を解除して購読し直します。
ストアとしてのブラウザAPI
現在の値を持ち、値が変わるとイベントを発生させるものなら、何でもこの形に当てはまります。online と offline イベントを持つ navigator.onLine が典型的な例です。
ネットワークを切ると(またはブラウザの開発者ツールでOfflineに切り替えると)、再読み込みせずにテキストが変わります。第3引数は、navigator のないサーバーで描画するときに「オンラインと見なす」ことを示しています。フックの呼び出しを useOnlineStatus で包むと、どのコンポーネントでも使えるカスタムフックになります。
ウィンドウ幅も同じように扱えます。
コピーをいくつか追加してウィンドウの大きさを変えてください。すべてのコピーが同じ瞬間に同じ数値を表示します。各コピーが自分のリスナーを持ち、それぞれレンダリング中に幅を読むので、どれも1フレームも遅れません。
getSnapshotはキャッシュした値を返さなければならない
Reactは getSnapshot を頻繁に呼び、結果を参照で比べます。呼ぶたびに新しいオブジェクトや配列を作る関数は、常に変化しているように見えます。
// Broken: a new object on every call
function getSnapshot() {
return { count: store.count, user: store.user };
}
// Also broken: filter returns a new array every time
function getSnapshot() {
return store.todos.filter((t) => !t.done);
}
Reactはレンダリングし、getSnapshot を呼び、「違う」値を受け取り、また再レンダリングし、と続け、最後に「Maximum update depth exceeded」で止まります。開発中は、Reactはその前に「The result of getSnapshot should be cached to avoid an infinite loop」も記録します。ここのプレビューのような本番ビルドではその警告は省かれ、最後のエラーだけが短いコード(Minified React error #185)として報告されます。
直し方は、ストアのデータをイミュータブルに保つことです。変わるときはオブジェクトを置き換え、保存している参照をそのまま返します。
getSnapshot は add が置き換えるまで同じ state オブジェクトを返すので、コンポーネントは変更ごとに1回だけレンダリングされます。フィルターはスナップショットを読んだ後のコンポーネントの中で行っているので安全です。代わりにストアの中でフィルターしたいなら、データが変わったときにフィルター済みの配列を計算して保存し、getSnapshot がその保存したコピーを返せるようにします。
getServerSnapshotとハイドレーション
サーバーにはwindowも navigator も購読もありません。第3引数は、そこで何を描画するかをReactに伝えます。
const width = useSyncExternalStore(
subscribe,
() => window.innerWidth, // in the browser
() => 1024 // on the server, and during hydration
);
Reactは、サーバーのHTMLをハイドレーションするブラウザでの最初のレンダリングにも getServerSnapshot を使うので、両者は一致します。ハイドレーションの直後に getSnapshot を読み、本当の値が違っていれば、それでもう一度レンダリングします。第3引数がないと、サーバーレンダリングは「Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.」を投げます。コンポーネントの上に <Suspense> 境界があれば、サーバーはその境界のフォールバックを送り、ブラウザが中身を代わりに描画します。境界がなければ、サーバーレンダリングは失敗します。
useSyncExternalStoreとuseEffect+useStateの違い
エフェクトで購読することもできます。
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const update = () => setOnline(navigator.onLine);
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return online;
}
これでも動きますが、弱点が2つあります。最初のレンダリングでは常に初期の推測値が表示され、本当の値はエフェクトが実行された後、1回遅れて届きます。また並行レンダリング(たとえばトランジション中)では、Reactはレンダリングを途中で一時停止することがあります。停止中にストアが変わると、前後でレンダリングされたコンポーネントが違う値を表示することがあります。この食い違いをティアリングと呼びます。useSyncExternalStore はレンダリング中に値を読み、ストアが変わっていればReactにレンダリングを同期的にやり直させるので、すべてのコンポーネントが同じ値を見ます。
データがReactの外にあるときに使ってください。自作のストアのモジュール、ブラウザのAPI、サードパーティのライブラリなどです。ほとんどの状態管理ライブラリ(Redux、Zustandなど)は、自分のフックの中でこれを呼んでくれます。自分のコンポーネントに属するデータには、useState、useReducer、コンテキストが引き続き正しい道具です。
よくある質問
useSyncExternalStoreは何に使いますか?
Reactが持っておらず、自分で変わることのあるデータを読むために使います。Reactの外で書かれたストア、サードパーティの状態管理ライブラリ、navigator.onLine やウィンドウ幅のようなブラウザの値などです。ストアが変わったことをReactに知らせるたびに、コンポーネントは再レンダリングされます。
subscribeとgetSnapshotは何をしますか?
subscribe(callback) はストアの監視を始め、変更のたびに callback を呼び、監視をやめる関数を返します。getSnapshot() は現在の値を返します。Reactはレンダリング中と各通知の後に getSnapshot を呼び、Object.is で値が変わっていたときだけ再レンダリングします。
getSnapshotがキャッシュした値を返さなければならないのはなぜですか?
Reactは getSnapshot の各呼び出しの結果を前回のものと比べるからです。毎回新しいオブジェクトや配列を返すと、Reactは常に変化があると見なし、再レンダリングし、また getSnapshot を呼び、「Maximum update depth exceeded」を投げるまでループします。データが本当に変わるまでは同じ参照を返してください。
getServerSnapshotとは何ですか?
省略可能な第3引数です。サーバーレンダリング中と、ブラウザでのハイドレーション中に使う値を返し、両方が同じHTMLを生成するようにします。これがないと、コンポーネントはサーバーで「Missing getServerSnapshot」を投げ、最も近い <Suspense> 境界の下の中身は代わりにブラウザで描画されます。
useSyncExternalStoreとuseEffect+useStateのどちらを使うべきですか?
外のデータを購読するなら useSyncExternalStore を選んでください。レンダリング中に値を読むので、最初のレンダリングからすでに正しく、並行レンダリング中でもすべてのコンポーネントが同じ値を見ます。useEffect と useState だと、古い値で1回レンダリングされ、一時的にコンポーネントごとに違う値が表示されることがあります。