数年ぶりの投稿です。昔の記事は今見ると恥ずかしくなってしまい、削除してしまいました。
あまりにも長い間こういった記事を書いていなかったのですが、 最近キャッチアップを行ったuseSyncExternalStore について、練習がてら理解した内容をまとめていきます。
間違っている部分や、補足した方がよい点などがあればご指摘いただけると助かります。
useSyncExternalStore
概要
useSyncExternalStore は、React の外にあるデータ(外部ストア)を、React コンポーネントで安全に使うためのフックです。
ここでいう外部ストアとは、React の useState や useContext で管理されていないデータのことを指します(例:状態管理ライブラリ、ブラウザ API、独自ストアなど)
useSyncExternalStore を使うことで、
- 外部ストアの値を React から参照できる
- 外部ストアが更新されたときに、React が正しく再レンダーされる
という状態を、React のレンダリング仕様に沿った形で実現できます。
React 18 以降で導入されたフックで、 Concurrent Mode や SSR(サーバーサイドレンダリング)といった新しいレンダリング方式でも安全に動作するよう設計されています。
前提
言葉を知っていないとこの先が少し読みづらくなるため、前提となる用語をここにまとめています。
サブスクライブ(subscribe)とは
サブスクライブとは、「何かの変更を監視して、変わったら通知を受け取る仕組み」のことです。
React の useSyncExternalStore におけるサブスクライブは、外部ストアの状態が変わったときにReact に再レンダーする必要があることを通知する 役割を持ちます。
イメージ
- 外部ストア側:値が変わったら React に通知
- React 側:通知を受け取ったら再レンダーを行う
subscribe((onStoreChange) => {
// ストアが更新されたら onStoreChange を呼ぶ
return () => {
// 監視を解除する処理
}
})
subscribe は 値そのものを返す役割ではありません。あくまで 変更を検知して React に通知するだけ です。
スナップショット(snapshot)とは
スナップショットとは、「ある瞬間のストアの状態を切り取った値」のことです。
useSyncExternalStore では、React が再レンダーするたびに
getSnapshot を呼び出して「今この瞬間のストアの状態」を取得します。
const value = getSnapshot()
この value が、そのコンポーネントで使われる state の代わりになります。
この挙動には、以下のような特徴があります。
- 常に最新の値を取りに行く
- ただし React が必要なタイミングでだけ取得する
外部ストアを直接読むのではなく、「その瞬間の状態」を使うという性質があるため、snapshot(状態の写し) と呼ばれています。
そのため、「データを取得する」というよりも、「外部ストアの状態を覗きに行く」というイメージの方が近いかもしれません。
useSyncExternalStore の使用方法
useSyncExternalStore は、次のような場面で使用します。
- Redux/Zustand/MobX など React 外の状態管理ライブラリと同期したいとき
- ブラウザ API など React 外の値にサブスクライブしたいとき(例:ネットワーク状態、Window サイズなど)
- SSR において外部データをサーバとクライアントで一致させたいとき
外部ストアの情報を取得する際に useEffect + setState を使っている箇所のリファクタには必ずしも最適とは限りません。
useEffect + setState は、
- React の state を持つ
- 副作用で state を更新する
という 「状態を作る」ための構成です。
一方 useSyncExternalStore は、
- state を持たない
- 外部ストアを 読むだけ
- React に「再レンダーしていいよ」と知らせるだけ
という役割を持ちます。
そのため、両者はそもそも用途が異なります。
これは、useSyncExternalStore が 「状態を持つためのフック」ではなく、 「外部ストアを React に安全に接続するためのフック」 という位置づけだからです。
useSyncExternalStore の引数について
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot? // オプション(SSR用)
);
この 3 つの引数は、それぞれが明確に役割分担されています。
ここが一番大切な内容です。
subscribe とは
subscribeは、外部ストアの変更を React に通知するための関数で、ここで外部ストアのイベントなどを登録します
- 外部ストアの状態変化を監視する
- React が提供する変更通知用コールバックを受け取る
- ストアが更新されたときに、そのコールバックを呼び出す
- サブスクライブ解除用の関数を返す
subscribe((onStoreChange) => {
// ストアの状態が変わったら onStoreChange を呼ぶ
return () => {
// 監視解除処理
}
})
subscribe 自体は値を返しません。
あくまで 「変わったことを知らせる」役割だけを持ちます。
getSnapshot とは
getSnapshotは、外部ストアの 「今この瞬間の状態」を返す関数です。
- React が再レンダーするタイミングで呼ばれる
- 返された値が、コンポーネント内で使われる state の代わりになる
- 自分から変更通知を行うことはない
const snapshot = getSnapshot()
getSnapshot は
- いつ呼ばれるか → React が決める
- 何を返すか → 開発者が定義する
という 受動的な役割を担っています。
注意点(参照の安定性)
- ストアの状態が変わっていない場合は、同じ参照の値を返す必要がある
- 毎回新しいオブジェクトを返すと、不要な再レンダーやエラーの原因になる
これは公式ドキュメントにも記載されている注意点ですが、実装時に実際にここでハマりました。
// 悪い例
const getSnapshot = () => {
return {
...storeValue,
}
}
この場合、ストアの中身は変わっていない
しかし getSnapshot の返り値は毎回新しい参照という状態になります。
その結果、
- React が getSnapshot を呼ぶ
- 前回と参照が違うため「値が変わった」と判定される
- 再レンダーが発生する
- 再レンダー時に再び
getSnapshotが呼ばれる - また新しいオブジェクトが返る
という流れになり、
無限に再レンダーが走る可能性があります。
useSyncExternalStore では、値が変わったかどうかを参照の同一性で判断するため、getSnapshotの返り値はできるだけ安定させる必要があります。
参照の同一性とは、値の中身ではなく同じオブジェクト(参照)を指しているかどうかで等価性を判断することを指します。
自分の場合は、この問題への対処としてuseRef を使ってスナップショットの参照を安定させました。
この点は、useStateや useEffectと同じ感覚で書いてしまうと気付きにくいポイントだと感じました。
getServerSnapshot とは
getServerSnapshotは、SSR(サーバーサイドレンダリング)時に使用される
スナップショットを返す関数です。
- サーバー環境ではブラウザ API やクライアント専用のストアが使えない
- そのため、サーバー用の安全な初期値を返すために用意されている
- サーバーとクライアントの初期描画結果を揃えることでhydration mismatch を防ぐ役割を持つ
そもそも SSR 時にはブラウザ API が使えず、外部ストアやクライアント専用の状態がまだ利用できないケースが多くあります。
そのため getServerSnapshot は、そういった状況でも安全に返せる固定の初期値のようなイメージで捉えると理解しやすいと感じました。
SSR と CSR(初回)の値が合わないときなど、初回描画のみ一致させたいケースに最適なオプションだと考えています。
SSR を行っていない場合や、
クライアントとサーバーで同じ値を返せる場合は省略できます。
まとめ
| 項目 | 内容 |
|---|---|
| 用途 | React 外のストア・ブラウザ API と同期 |
| 仕組み | subscribe / getSnapshot を使って更新を同期 |
| SSR 対応 | getServerSnapshot を optional で利用 |
| 注意点 | getSnapshot の参照安定性、subscribe の安定性 |
useSyncExternalStore を使うか迷ったときの目安
※ 個人的なメモも兼ねた判断基準です
使う可能性が高いケース
-
既に外部ストアが存在する
- Redux / Zustand / MobX などの状態管理ライブラリ
- 独自に実装されたグローバルストア
- ブラウザ API をラップしたストア
-
外部ストアの値をReactで読むだけでよい
- React 側で値を直接更新する必要がない
-
setStateのような操作が不要
-
外部ストアの変更に応じて再レンダーさせたい
- 「変更されたら再描画する」が要件として明確
-
SSR / CSR 間のズレ(hydration mismatch)を解消したい
- 初回描画時の値をサーバーとクライアントで揃えたい
-
getServerSnapshotを使って安全な初期値を返したい
-
Concurrent Mode を意識した安全な同期が必要
- render 中に値が変わる可能性がある
- React のレンダリングモデルに沿って扱いたい
使わない方がよいケース
-
Reactに状態を持たせたい
- フォーム入力
- UI の一時的な状態(モーダル開閉、タブ選択など)
- コンポーネントローカルな状態管理
-
非同期 fetch の結果を保存したい
- API レスポンスを state として保持したい
- ローディング / エラー状態を管理したい
→useState/useEffect/useQueryなどが適切
-
外部ストアが存在しない
- 単に値を計算して表示したいだけ
- subscribe の仕組みを新たに作る必要がある
→ 無理に使うと実装が複雑になる
-
React から外部データを直接更新したい
- 双方向データバインディングが必要
- state の更新が主目的
→useSyncExternalStoreは向いていない
個人的には、useSyncExternalStore は積極的に使うフックではなく、必要になったときに正しく使えるよう理解しておくフックだと感じました。
参考元: