0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Hooks を Hackしよう!【Part17: useSyncExternalStoreをふかぼってみよう】

Last updated at Posted at 2025-12-19

useSyncExternalStore は、外部ストアへのサブスクライブを可能にする React のフックです。このフックを使用することで、React コンポーネントが外部の状態管理ライブラリやブラウザ API などと同期することができます。

1. なぜ useSyncExternalStore が必要か

1.1 React コンポーネントのデータ取得の課題

React コンポーネントは通常、propsstate を通じてデータを管理します。しかし、以下のようなケースでは、React の外部にあるデータストアと同期する必要があります:

  • サードパーティの状態管理ライブラリ(例: Redux, Zustand)
  • ブラウザ API(例: navigator.onLine
  • カスタムイベントシステム

これらの外部ストアと同期するために、useSyncExternalStore が提供されます。

2. useSyncExternalStore の基本的な使い方

2.1 API

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
引数 説明
subscribe ストアの変更を監視するための関数。変更時にコールバックを呼び出します。
getSnapshot 現在のストアのスナップショットを取得する関数。
getServerSnapshot (オプション)サーバーレンダリング時に使用されるスナップショットを取得する関数。

2.2 使用例

外部ストアへのサブスクライブ

以下は、todosStore という外部ストアにサブスクライブする例です:

import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore';

function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

ブラウザ API へのサブスクライブ

ブラウザの navigator.onLine を使用して、ネットワーク接続状態を監視する例です:

import { useSyncExternalStore } from 'react';

function useOnlineStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine
  );
}

function OnlineIndicator() {
  const isOnline = useOnlineStatus();
  return <div>{isOnline ? '✅ Online' : '❌ Offline'}</div>;
}

3. useSyncExternalStore の内部構造を徹底解剖

useSyncExternalStore を使うたびに、React の内部では複数のモジュールが連携して動いています。この章では、facebook/react リポジトリの実際のコードを追いながら、その動作原理を解説します。

3.0 全体像: useSyncExternalStore が動く仕組み

🎣 useSyncExternalStore(フック呼び出し)
   ↓
📝 スナップショットを取得
   ↓
📋 StoreInstance オブジェクトを作成
   ↓
⚡ subscribeToStore を Effect として登録
   ↓
🔄 ストア変更時に forceStoreRerender で再レンダー

重要なポイント:useSyncExternalStore は内部で Effect を使用し、ストアの変更を監視して同期的に再レンダーをトリガーします!

3.1 エントリポイント: packages/react/src/ReactHooks.js

// packages/react/src/ReactHooks.js

export function useSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
}

3.2 コア実装: mountSyncExternalStore

初回レンダー時の処理

// packages/react-reconciler/src/ReactFiberHooks.js

function mountSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  const fiber = currentlyRenderingFiber;
  const hook = mountWorkInProgressHook();

  let nextSnapshot;
  const isHydrating = getIsHydrating();
  if (isHydrating) {
    if (getServerSnapshot === undefined) {
      throw new Error(
        'Missing getServerSnapshot, which is required for ' +
          'server-rendered content. Will revert to client rendering.',
      );
    }
    nextSnapshot = getServerSnapshot();
  } else {
    nextSnapshot = getSnapshot();
  }

  // Read the current snapshot from the store on every render. This breaks the
  // normal rules of React, and only works because store updates are
  // always synchronous.
  hook.memoizedState = nextSnapshot;
  const inst: StoreInstance<T> = {
    value: nextSnapshot,
    getSnapshot,
  };
  hook.queue = inst;

  // Schedule an effect to subscribe to the store.
  mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);

  // Schedule an effect to update the mutable instance fields.
  fiber.flags |= PassiveEffect;
  pushSimpleEffect(
    HookHasEffect | HookPassive,
    createEffectInstance(),
    updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
    null,
  );

  return nextSnapshot;
}

💡 StoreInstance とは
StoreInstance は、現在のスナップショット値と getSnapshot 関数を保持するオブジェクトです。これにより、ストアの変更を検出する際に最新の参照を使用できます。

3.3 更新時の処理: updateSyncExternalStore

// packages/react-reconciler/src/ReactFiberHooks.js

function updateSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  const fiber = currentlyRenderingFiber;
  const hook = updateWorkInProgressHook();
  
  // Read the current snapshot from the store on every render.
  let nextSnapshot;
  const isHydrating = getIsHydrating();
  if (isHydrating) {
    if (getServerSnapshot === undefined) {
      throw new Error(
        'Missing getServerSnapshot, which is required for ' +
          'server-rendered content. Will revert to client rendering.',
      );
    }
    nextSnapshot = getServerSnapshot();
  } else {
    nextSnapshot = getSnapshot();
  }
  
  const prevSnapshot = (currentHook || hook).memoizedState;
  const snapshotChanged = !is(prevSnapshot, nextSnapshot);
  if (snapshotChanged) {
    hook.memoizedState = nextSnapshot;
    markWorkInProgressReceivedUpdate();
  }
  const inst = hook.queue;

  updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
    subscribe,
  ]);

  // Whenever getSnapshot or subscribe changes, we need to check in the
  // commit phase if there was an interleaved mutation.
  if (
    inst.getSnapshot !== getSnapshot ||
    snapshotChanged ||
    (workInProgressHook !== null &&
      workInProgressHook.memoizedState.tag & HookHasEffect)
  ) {
    fiber.flags |= PassiveEffect;
    pushSimpleEffect(
      HookHasEffect | HookPassive,
      createEffectInstance(),
      updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
      null,
    );
  }

  return nextSnapshot;
}

3.4 ストアへのサブスクライブ: subscribeToStore

// packages/react-reconciler/src/ReactFiberHooks.js

function subscribeToStore<T>(
  fiber: Fiber,
  inst: StoreInstance<T>,
  subscribe: (() => void) => () => void,
): any {
  const handleStoreChange = () => {
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceStoreRerender(fiber);
    }
  };
  // Subscribe to the store and return a clean-up function.
  return subscribe(handleStoreChange);
}

💡 subscribeToStore の役割
この関数は mountEffect / updateEffect のセットアップ関数として登録されます。subscribe 関数にコールバック(handleStoreChange)を渡し、ストアの変更時に React に通知します。

3.5 スナップショットの変更検出: checkIfSnapshotChanged

// packages/react-reconciler/src/ReactFiberHooks.js

function checkIfSnapshotChanged<T>(inst: StoreInstance<T>): boolean {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

💡 Object.is による比較
is 関数は Object.is と同じ動作をします。これにより、プリミティブ値や参照の厳密な比較が行われます。スナップショットが変わった場合のみ再レンダーがトリガーされます。

3.6 強制再レンダー: forceStoreRerender

// packages/react-reconciler/src/ReactFiberHooks.js

function forceStoreRerender(fiber: Fiber) {
  const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
  if (root !== null) {
    scheduleUpdateOnFiber(root, fiber, SyncLane);
  }
}

💡 SyncLane の重要性
forceStoreRerenderSyncLane(同期レーン)を使用します。これにより、外部ストアの変更は即座に反映され、ティアリング(tearing)を防ぎます。

3.7 StoreInstance の更新: updateStoreInstance

// packages/react-reconciler/src/ReactFiberHooks.js

function updateStoreInstance<T>(
  fiber: Fiber,
  inst: StoreInstance<T>,
  nextSnapshot: T,
  getSnapshot: () => T,
): void {
  // These are updated in the passive phase
  inst.value = nextSnapshot;
  inst.getSnapshot = getSnapshot;

  // Something may have been mutated in between render and commit. This could
  // have been in an event that fired before the passive effects, or it could
  // have been in a layout effect. In that case, we would have used the old
  // snapshot and getSnapshot values to bail out. We need to check one more time.
  if (checkIfSnapshotChanged(inst)) {
    // Force a re-render.
    forceStoreRerender(fiber);
  }
}

3.8 一貫性チェック: pushStoreConsistencyCheck

// packages/react-reconciler/src/ReactFiberHooks.js

function pushStoreConsistencyCheck<T>(
  fiber: Fiber,
  getSnapshot: () => T,
  renderedSnapshot: T,
): void {
  fiber.flags |= StoreConsistency;
  const check: StoreConsistencyCheck<T> = {
    getSnapshot,
    value: renderedSnapshot,
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.stores = [check];
  } else {
    const stores = componentUpdateQueue.stores;
    if (stores === null) {
      componentUpdateQueue.stores = [check];
    } else {
      stores.push(check);
    }
  }
}

💡 一貫性チェックの目的
並行レンダリング中に外部ストアが変更された場合、レンダー中に取得したスナップショットがコミット時に古くなっている可能性があります。pushStoreConsistencyCheck は、コミット前にスナップショットの一貫性を確認します。

3.9 内部の流れ図

このように、useSyncExternalStore は React のライフサイクルと密接に連携し、外部ストアの変更を効率的かつ安全に反映させる仕組みを提供しています。

4. ユースケース

4.1 外部状態管理ライブラリとの連携

Redux や Zustand などの外部状態管理ライブラリと React コンポーネントを同期する際に使用します。

// シンプルなストアの実装
function createStore<T>(initialState: T) {
  let state = initialState;
  const listeners = new Set<() => void>();

  return {
    getSnapshot: () => state,
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    setState: (newState: T) => {
      state = newState;
      listeners.forEach(listener => listener());
    },
  };
}

// ストアの作成
const todosStore = createStore<Todo[]>([]);

// コンポーネントでの使用
function TodoList() {
  const todos = useSyncExternalStore(
    todosStore.subscribe,
    todosStore.getSnapshot
  );

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

4.2 ブラウザ API の監視

ブラウザのイベントやAPIを監視するカスタムフックを作成できます。

ネットワーク状態の監視

function useOnlineStatus() {
  const subscribe = useCallback((callback: () => void) => {
    window.addEventListener('online', callback);
    window.addEventListener('offline', callback);
    return () => {
      window.removeEventListener('online', callback);
      window.removeEventListener('offline', callback);
    };
  }, []);

  const getSnapshot = () => navigator.onLine;
  const getServerSnapshot = () => true; // サーバーでは常にオンラインと仮定

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

function NetworkIndicator() {
  const isOnline = useOnlineStatus();
  return (
    <div className={isOnline ? 'online' : 'offline'}>
      {isOnline ? '✅ オンライン' : '❌ オフライン'}
    </div>
  );
}

ウィンドウサイズの監視

function useWindowSize() {
  const subscribe = useCallback((callback: () => void) => {
    window.addEventListener('resize', callback);
    return () => window.removeEventListener('resize', callback);
  }, []);

  const getSnapshot = () => ({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  // サーバーではデフォルト値を返す
  const getServerSnapshot = () => ({
    width: 1024,
    height: 768,
  });

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

function ResponsiveComponent() {
  const { width, height } = useWindowSize();
  return <div>Window: {width} x {height}</div>;
}

⚠️ getSnapshot の注意点
getSnapshot が毎回新しいオブジェクトを返すと、無限ループが発生します。上記の例では説明のため単純化していますが、実際には useMemo やグローバル変数でキャッシュする必要があります。

ウィンドウサイズの監視(改良版)

// グローバルにキャッシュ
let cachedSize = { width: 0, height: 0 };

function getWindowSize() {
  const newSize = {
    width: window.innerWidth,
    height: window.innerHeight,
  };
  // 値が変わった場合のみ新しいオブジェクトを返す
  if (cachedSize.width !== newSize.width || cachedSize.height !== newSize.height) {
    cachedSize = newSize;
  }
  return cachedSize;
}

function useWindowSize() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    getWindowSize,
    () => ({ width: 1024, height: 768 })
  );
}

4.3 メディアクエリの監視

function useMediaQuery(query: string) {
  const subscribe = useCallback(
    (callback: () => void) => {
      const mediaQuery = window.matchMedia(query);
      mediaQuery.addEventListener('change', callback);
      return () => mediaQuery.removeEventListener('change', callback);
    },
    [query]
  );

  const getSnapshot = () => window.matchMedia(query).matches;
  const getServerSnapshot = () => false; // サーバーでは false を返す

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

function DarkModeComponent() {
  const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
  return <div>{isDarkMode ? '🌙 ダークモード' : '☀️ ライトモード'}</div>;
}

4.4 ブラウザ履歴の監視

function useHistoryState() {
  const subscribe = useCallback((callback: () => void) => {
    window.addEventListener('popstate', callback);
    return () => window.removeEventListener('popstate', callback);
  }, []);

  const getSnapshot = () => window.history.state;
  const getServerSnapshot = () => null;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

4.5 カスタムフックへの抽出

// 汎用的な外部ストア用フック
function useStore<T>(store: {
  subscribe: (callback: () => void) => () => void;
  getSnapshot: () => T;
  getServerSnapshot?: () => T;
}): T {
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot ?? store.getSnapshot
  );
}

// 使用側
function UserProfile() {
  const user = useStore(userStore);
  return <div>{user.name}</div>;
}

5. パフォーマンスと注意点

5.1 getSnapshot の結果をキャッシュする

// ❌ 毎回新しいオブジェクトを返す → 無限ループ
function useWindowSize() {
  return useSyncExternalStore(
    subscribe,
    () => ({ width: window.innerWidth, height: window.innerHeight })
  );
}

// ✅ キャッシュを使用
let cached = { width: 0, height: 0 };
function getSnapshot() {
  const current = { width: window.innerWidth, height: window.innerHeight };
  if (cached.width !== current.width || cached.height !== current.height) {
    cached = current;
  }
  return cached;
}

function useWindowSize() {
  return useSyncExternalStore(subscribe, getSnapshot);
}

5.2 subscribe を安定させる

// ❌ 毎回新しい関数を渡す → サブスクリプションが毎回再登録
function Component({ roomId }) {
  const data = useSyncExternalStore(
    (callback) => store.subscribe(roomId, callback), // 毎回新しい関数
    () => store.getSnapshot(roomId)
  );
}

// ✅ useCallback でメモ化
function Component({ roomId }) {
  const subscribe = useCallback(
    (callback) => store.subscribe(roomId, callback),
    [roomId]
  );
  
  const getSnapshot = useCallback(
    () => store.getSnapshot(roomId),
    [roomId]
  );

  const data = useSyncExternalStore(subscribe, getSnapshot);
}

5.3 サーバーレンダリング時の getServerSnapshot

// ❌ サーバーで window にアクセス → エラー
function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine // サーバーでは navigator が undefined
  );
}

// ✅ getServerSnapshot を提供
function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true // サーバーではデフォルト値を返す
  );
}

5.4 useEffect との使い分け

ケース 推奨フック
外部ストアの値を読み取り、変更を購読 useSyncExternalStore
副作用の実行(データフェッチ、DOM 操作など) useEffect
ティアリングを防ぎたい並行レンダリング useSyncExternalStore

💡 ティアリング(Tearing)とは
並行レンダリング中に外部ストアが変更されると、同じレンダー内で異なるコンポーネントが異なる値を読み取る可能性があります。これを「ティアリング」と呼びます。useSyncExternalStoreSyncLane を使用してこれを防ぎます。

6. トラブルシューティング

6.1 getSnapshot が毎回異なる値を返す

原因: 毎レンダーで新しいオブジェクトや配列を作成している

// ❌ 無限ループの原因
function useStore() {
  return useSyncExternalStore(
    subscribe,
    () => ({ value: store.value }) // 毎回新しいオブジェクト
  );
}

解決策: 結果をキャッシュする

// ✅ キャッシュを使用
let cached = { value: null };
function getSnapshot() {
  if (cached.value !== store.value) {
    cached = { value: store.value };
  }
  return cached;
}

6.2 subscribe が毎回異なる関数を返す

原因: インラインで関数を定義している

// ❌ 毎回新しい関数
<MyComponent
  subscribe={(cb) => store.subscribe(cb)}
/>

解決策: useCallback でメモ化する

// ✅ メモ化
const subscribe = useCallback((cb) => store.subscribe(cb), []);

6.3 サーバーレンダリング時のエラー

原因: getServerSnapshot が提供されていない

// ❌ SSR でエラー
Error: Missing getServerSnapshot, which is required for server-rendered content.

解決策: getServerSnapshot を提供する

// ✅ getServerSnapshot を追加
useSyncExternalStore(
  subscribe,
  getSnapshot,
  () => defaultValue // サーバー用のデフォルト値
);

6.4 開発環境で2回呼び出される

原因: Strict Mode での意図的な動作

// 開発環境では subscribe が2回呼び出される(Strict Mode)
useEffect(() => {
  console.log('subscribe');
  return () => console.log('unsubscribe');
}, []);
// 出力: subscribe → unsubscribe → subscribe

解決策: これは正常な動作です。クリーンアップが正しく実装されていれば問題ありません。

7. まとめ

この記事で解説した内容は、公式ドキュメントと facebook/react リポジトリの以下のファイルに基づいています:

  • useSyncExternalStore は外部ストアとコンポーネントを同期するためのフック
  • 内部的には Effect を使用してサブスクライブし、SyncLane で同期的に再レンダー
  • StoreInstance オブジェクトでスナップショットと getSnapshot 関数を管理
  • 並行レンダリング時のティアリングを防ぐための一貫性チェックが組み込まれている
  • getSnapshot は安定した値を返し、subscribe はメモ化することが重要

使い分けの指針: 外部ストアの値を購読して同期する場合は useSyncExternalStore、副作用の実行には useEffect を使用。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?