1. ホーム
  2. React

【React】useSyncExternalStore の使い方|外部ストアの状態を安全に購読する

Share

React でアプリを作っていると、navigator.onLine のようなブラウザの状態や、React の外側で管理されているストアの値を、コンポーネントの表示に反映したい場面が出てきます。こうした「React の外にある状態」を安全に購読するために用意されたのが useSyncExternalStore フックです。React 18 で追加されました。この記事では、useSyncExternalStore が何のためのフックなのか、3つの引数がそれぞれ何を担当するのか、そしてオンライン状態やウィンドウ幅を購読する実践的なカスタムフックの作り方までを、初心者の方にも分かるように順を追って解説します。

useSyncExternalStore は何のためのフックか

useSyncExternalStore は、React が管理していない外部のストア(external store)を、React の状態として購読するためのフックです。ここでいう外部のストアとは、たとえばブラウザが持つ navigator.onLinewindow.matchMedia の状態、あるいは Redux や Zustand のような状態管理ライブラリが内部で持っている状態、さらには自作のグローバルな値などを指します。いずれも useStateuseReducer の外側で値が変化するため、React はその変化を自動では検知できません。

これまでは、こうした外部の値を useEffect でイベントを購読し、useState にコピーして表示する、という書き方が一般的でした。多くの場合はそれで動きますが、React 18 で導入された Concurrent Rendering(並行レンダリング)の環境では、この方法だと表示の不整合が起きる可能性があります。useSyncExternalStore は、外部ストアの値をレンダリングと矛盾なく読み取れるように設計された、より安全な購読方法です。

3つの引数の役割

useSyncExternalStore のシグネチャは次のとおりです。戻り値は、現在のストアのスナップショット(値)です。

シグネチャ
const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot?
);

それぞれの引数が何を担当するのかを整理すると、次のようになります。

引数役割
subscribeストアの変更を購読する関数。変更時に呼ばれるコールバックを受け取って登録し、購読を解除するためのクリーンアップ関数を返す。
getSnapshot現在のストアのスナップショット(値)を返す関数。React はこの戻り値を前回と比較して、再レンダリングが必要か判断する。
getServerSnapshot?省略可能。サーバーサイドレンダリング(SSR)時や初回のハイドレーション時に使われる初期値を返す関数。

subscribe は「値が変わったら教えてね」と登録する係、getSnapshot は「今の値はこれです」と答える係、と考えると分かりやすいです。React は subscribe 経由で変更の通知を受け取ると getSnapshot を呼び、返ってきた値が前回と変わっていれば再レンダリングを行います。

オンライン状態を購読する useOnlineStatus

もっとも分かりやすい実践例として、ブラウザのオンライン/オフライン状態を購読するカスタムフック useOnlineStatus を作ってみます。ブラウザは接続状態が変わると windowonline / offline イベントを発火し、現在の状態は navigator.onLine で取得できます。これらを subscribegetSnapshot に結びつけます。

useOnlineStatus.ts
import { useSyncExternalStore } from "react";

// 変更を購読する関数:online / offline イベントを登録し、
// クリーンアップ関数(購読解除)を返す
function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

// 現在のスナップショット(値)を返す関数
function getSnapshot() {
  return navigator.onLine;
}

// SSR 時の初期値。サーバーには navigator がないので true を返す
function getServerSnapshot() {
  return true;
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
  );
}

あとはコンポーネントの中でこのフックを呼ぶだけです。オンライン状態が変化するたびに、React が自動で再レンダリングしてくれます。

StatusBar.tsx
import { useOnlineStatus } from "./useOnlineStatus";

export function StatusBar() {
  const isOnline = useOnlineStatus();

  return (
    <p>
      現在の状態:{isOnline ? "オンライン ✅" : "オフライン ⚠️"}
    </p>
  );
}

この useOnlineStatus は一度作っておけば、アプリ内のどのコンポーネントからでも呼び出せます。useEffectuseState を使う従来の書き方に比べて、ロジックがフックの中にきれいにまとまり、購読解除の書き忘れも防げるのが利点です。

ウィンドウ幅を購読する例

同じパターンは、ほかのブラウザ状態にも応用できます。今度は window のリサイズを購読して、現在のウィンドウ幅を返すカスタムフック useWindowWidth を作ってみましょう。subscribe では resize イベントを登録し、getSnapshot では window.innerWidth を返します。

useWindowWidth.ts
import { useSyncExternalStore } from "react";

function subscribe(callback: () => void) {
  window.addEventListener("resize", callback);
  return () => window.removeEventListener("resize", callback);
}

function getSnapshot() {
  return window.innerWidth;
}

function getServerSnapshot() {
  // サーバーには window がないので、仮の初期値を返す
  return 1024;
}

export function useWindowWidth() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
  );
}

getSnapshot が数値(window.innerWidth)を返している点に注目してください。数値や文字列、真偽値のようなプリミティブ値は、値そのもので比較されるため、リサイズのたびに幅が変わっても正しく再レンダリングされます。ここが次に説明する「同じ参照を返す」という注意点と深く関わってきます。

getSnapshot が毎回同じ参照を返すべき理由

useSyncExternalStore でもっともつまずきやすいのが、getSnapshot の戻り値の扱いです。React は getSnapshot が返した値を、前回の値と Object.is で比較して、変化があれば再レンダリングします。ここで、値が実際には変わっていないのに毎回新しいオブジェクトや配列を作って返すと、React は「値が変わった」と判断し続け、再レンダリングが止まらなくなります。最悪の場合は無限ループになります。

やってはいけない例
// ❌ 呼ばれるたびに新しいオブジェクトを生成している
function getSnapshot() {
  return {
    online: navigator.onLine,
  };
}
// → 中身が同じでも参照が毎回変わるため、
//   React が変化したと誤認して再レンダリングが止まらない

これを避けるには、getSnapshot はできるだけプリミティブ値(真偽値・数値・文字列)を返すのが安全です。navigator.onLinewindow.innerWidth をそのまま返している先ほどの例は、まさにこの理由から問題が起きません。

どうしてもオブジェクトを返す必要がある場合は、ストア側で値をキャッシュしておき、内容が本当に変わったときだけ新しいオブジェクトを作るようにします。こうすれば、値が変わらない限り getSnapshot は同じ参照を返し続けるため、不要な再レンダリングを防げます。

オブジェクトを返す場合の対処
let cache = { online: navigator.onLine };

function getSnapshot() {
  const online = navigator.onLine;
  // 値が変わったときだけ新しいオブジェクトを作る
  if (online !== cache.online) {
    cache = { online };
  }
  // 変わっていなければ同じ参照を返す
  return cache;
}

useEffect + useState との違い

「同じことは useEffectuseState でもできるのでは?」と感じるかもしれません。実際、多くの場面では従来のやり方でも動作します。違いが表れるのは、React 18 の Concurrent Rendering が絡むケースです。並行レンダリングでは、React が1回のレンダリング処理を途中で中断したり、再開したりすることがあります。その途中で外部ストアの値が変わると、画面内の一部だけが古い値、別の部分が新しい値を表示してしまうテアリング(tearing)という不整合が起こり得ます。

useSyncExternalStore は、この問題を防ぐために React 本体と協調して動作します。レンダリング中に外部ストアの値が変化した場合でも、画面全体が一貫した1つの値を映すことを保証してくれます。外部ストアを購読するときに useEffectuseState の自作パターンではなく専用フックを使うべきなのは、この安全性のためです。ライブラリ作者が状態管理ツールを React に統合する際にも、このフックが土台として使われています。

まとめ

useSyncExternalStore は、React の外側にある状態を安全に購読するための React 18 のフックです。subscribe で変更を購読し、getSnapshot で現在の値を返し、必要に応じて getServerSnapshot で SSR 用の初期値を用意する、という3つの引数で成り立っています。navigator.onLine を購読する useOnlineStatus や、ウィンドウ幅を購読する useWindowWidth のように、ブラウザの状態をカスタムフックにまとめると再利用しやすくなります。注意点は、getSnapshot が値の変わらない限り同じ参照を返すこと。毎回新しいオブジェクトを返すと再レンダリングが止まらなくなるため、基本はプリミティブ値を返すのが安全です。useEffectuseState の自作購読と違い、並行レンダリング下でもテアリングを防げる点が、このフックを使う最大の理由です。

参考ページ