React 18は、useSyncExternalStoreを追加しました。これは、ブラウザAPI、WebSocket、またはサードパーティ製のストアなど、Reactツリーの外にあるあらゆるソースからデータを読み取ることができるフックです。これにより、useStateとuseEffectを使ってデータをミラーリングするという従来のパターンで発生していた視覚的な不具合(グリッチ)を防ぐことができます。UIの一部が古いウィンドウサイズを表示している一方で、別の部分がすでに新しいサイズを反映しているといった、UIのちらつき(フリッカー)を目にしたことがあるなら、このフックこそが待ち望んでいた解決策です。
なぜ外部ステートに新しいフックが必要なのか
Reactコンポーネントは、常に自身のスコープを超えたもの(windowオブジェクト、Reduxストア、WebSocket接続など)を購読(subscribe)することができました。従来のアプローチは、useEffect内で購読を設定し、送られてくる値をsetStateを介してローカルステートにプッシュし、Reactに再レンダリングさせるというものでした。これはReactが同期的にレンダリングされる場合には問題なく動作しますが、React 18で導入されたコンカレントレンダリング(concurrent rendering)では、ブラウザが実際に描画を行う前にコンポーネントが複数回レンダリングされる可能性があります。このモードでは、「ローカルステートへのミラーリング」パターンによって**ティアリング(tearing)**が発生する可能性があります。つまり、単一のレンダリングサイクル中に、UIの異なる部分が同じ外部値の異なるスナップショットを読み取ってしまう現象です。
useSyncExternalStoreは、このティアリングを防ぐために構築されました。このフックは、外部ソースに対してスナップショット(現在の値)とsubscribe関数(変更を通知する方法)を要求します。Reactはコンポーネントがマウントされたときにsubscribeを呼び出し、アンマウント時に自動的に購読を解除します。getSnapshotによって返されるスナップショットが変更されるたびに、Reactはコンポーネントツリー全体に対して同じスナップショットを読み取るレンダリングをスケジュールし、一貫性を保証します。
必要な2つの関数
| 関数 | 役割 |
|---|---|
| subscribe | 外部の値が変更されるたびに呼び出される必要があるコールバックを受け取ります。クリーンアップ時にReactが呼び出すunsubscribe関数を返します。 |
| getSnapshot | 外部ソースから現在の値を返します。Reactがスナップショットを正しく比較できるように、参照の安定性(referentially stable)—つまり、レンダリングごとに同じ関数オブジェクトであること—が必要です。 |
ブラウザのonlineステータスに関する最小限の実装は以下の通りです。
function useOnlineStatus() {
return useSyncExternalStore(
(onChange) => {
window.addEventListener('online', onChange);
window.addEventListener('offline', onChange);
return () => {
window.removeEventListener('online', onChange);
window.removeEventListener('offline', onChange);
};
},
() => navigator.onLine
);
}
コンポーネントがuseOnlineStatus()を呼び出すと、navigator.onLineが実際に切り替わったときにのみReactが再レンダリングを行い、すべてのレンダリングで同じ値が確認されます。
フックの暴走を防ぐためのルール
コンポーネントが必要とする最小限のデータを返す。
外部ストアが大きなオブジェクトを保持していても、コンポーネントが特定のフィールドのみを必要としている場合は、そのフィールドだけを返してください。返り値が大きすぎると、不要な再レンダリングが発生します。getSnapshotをメモ化する。
レンダリングのたびに新しい関数を作成すると、Reactはそれを新しいソースとして扱い、無限ループに陥る可能性があります。useCallbackでラップするか、コンポーネントの外で定義してください。呼び出しのたびに新しいオブジェクトを返さないようにする。
新しいオブジェクト(例:{ count: store.getCount() })を返すと、レンダリングのたびに新しい参照が作成されます。これにより、Reactはスナップショットが変更されたと判断し、無限レンダリングループを引き起こします。プリミティブ値、文字列、数値、またはメモ化されたオブジェクトを使用してください。コンポーネント内部のステートには使用しない。
コンポーネントの内部のみに存在するデータには、引き続きuseStateが適切なツールです。useSyncExternalStoreは、ローカルステートには不要なオーバーヘッドを追加してしまいます。
使用すべき場面
- ブラウザAPI – ウィンドウサイズ、メディアクエリ、
navigator.onLine、バッテリーステータスなど。 - フレームワークに依存しないストア – Zustand、Redux、MobX、または購読APIを公開しているカスタムストア。
- Reactの外で更新されるミュータブルなソース – WebSocketメッセージ、IndexedDBの変更イベント、Service Workerの通知。
データソースがすでにReact内にある場合(例:親コンポーネントのステート)、useStateまたはContextを使用してください。
コミュニティの声
早期導入者からは、コンカレントレンダリングの設定において、ウィンドウのリサイズ時に発生していたちらつきがuseSyncExternalStoreによって解消されたという報告があります。一部のライブラリはすでに内部フックをこのAPIに切り替えており、Reactのバージョン間でのより予測可能な動作を約束しています。トレードオフとして、メンタルロードがわずかに高くなります。開発者は2つの関数の安定性を考慮し、呼び出しのたびに新しいオブジェクトを返さないように注意する必要があります。
次に注目すべきこと
Reactの今後のリリースでは、useSyncExternalStoreに関する規約が厳格化され、一般的なブラウザAPI用の組み込みヘルパーが追加される可能性があります。従来のuseEffectを模倣したパターンが非推奨にならないか、Reactの公式ブログを注視しておいてください。それまでの間、このフックは安定しており、パブリックAPIの一部であるため、既存の外部状態コードを安心してリファクタリングできます。
クイックテスト
ウィンドウサイズの同期、オンライン/オフラインの検知、およびシンプルなZustandストアを紹介するライブデモが https://usesyncexternalstore.vercel.app/ で公開されています。コメント付きのソースコードは https://github.com/dev48v/usesyncexternalstore で確認できます。
まとめ: useSyncExternalStoreは、Reactのレンダリングサイクルの外にあるあらゆる可変データに対して、Reactが信頼できるブリッジを提供し、ティアリングや不要な再レンダリングを防ぎます。これを外部ソースに適用し、スナップショットを最小限かつ安定した状態に保つことで、一貫性の維持という重い作業をReactに任せることができます。
