React useSyncExternalStoreを用いた外部状態管理フックの作成

React Frontend CustomHooks TypeScript
結論

外部ストア同期には useSyncExternalStore を使い、SSR用に getServerSnapshot を記述します。

// React公式仕様:ブラウザのオンライン状態を検知するカスタムフック
import { useSyncExternalStore } from 'react';

// 1. 購読関数 (subscribe)
function subscribe(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

// 2. クライアント側スナップショット取得 (getSnapshot)
function getSnapshot() {
  return navigator.onLine;
}

// 3. サーバー側スナップショット取得 (getServerSnapshot)
function getServerSnapshot() {
  return true; // SSR時の安全なデフォルト初期値
}

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

React公式仕様:Tearing(表示不整合)問題の解決

React 18 以降の Concurrent Rendering(並行レンダリング)では、コンポーネントツリーの描画が途中で中断・再開されます。

従来の useEffect + useState で外部ストアを同期させようとすると、画面上の上位コンポーネントと下位コンポーネントが描画途中で異なるデータバージョンを参照してしまい、UIの一部だけが古い表示に置かれる「Tearing(画面の引き裂かれ事故)」が発生します。

useSyncExternalStore は、描画中に値の変動を検知した場合に即座に同期再描画を起動させ、画面全体の整合性を保証します。


実際に起こる事故:無限レンダリングループと SSR エラー

事故1: getSnapshot が新しいオブジェクト参照を返し続けて無限ループ

関数は、値が変化していない時は常に同一のオブジェクト参照(Same Reference)を返さなければなりません。

// ❌ 事故:毎回新オブジェクトを生成するため Object.is 判定に失敗して無限ループ
function getSnapshot() {
  return { status: myStore.getStatus() }; // 毎回新規参照が発生して無限レンダリング
}

事故2: getServerSnapshot の省略による SSR Hydration Error

Next.js や Astro 等の SSR 環境で第3引数 getServerSnapshot を書き忘れたり、サーバー側で window オブジェクトを直接参照すると、サーバー生成HTMLとクライアントのハイドレーション時初期状態がズレて Hydration Error が発生します。


実装手順

  1. subscribe 関数内でイベントリスナーを登録し、クリーンアップ(removeEventListener)関数を返す
  2. getSnapshot では参照が変化しない純粋な値を返す
  3. SSR(Next.js/Astro等)で使用する場合は、第3引数 getServerSnapshot でサーバー安全なデフォルト値を指定する