Menu

React useSyncExternalStore:Reactの外のデータを購読する

useSyncExternalStoreは、小さなストアのモジュールやnavigator.onLineのようなブラウザのAPIなど、Reactの外にあるデータをコンポーネントに購読させ、そのデータが変わるたびに再レンダリングします。subscribeとgetSnapshot、スナップショットをキャッシュしなければならない理由、getServerSnapshotを学びます。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

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回レンダリングされ、一時的にコンポーネントごとに違う値が表示されることがあります。

Coddyのプログラミング言語のイラスト

Coddyでコードを学ぼう

始める