It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

useSyncExternalStoreを使って、Subscribe(購読)形式のデータ取得をhook化する

useSyncExternalStoreとは

react.dev

  • React18から追加された
  • 外部にあるデータソースをReact Hookに変換するためのhooks
  • 外部ネットワークやアプリケーション外から subscribe で取得する形式のデータをhooksでstoreとして扱うことができる

export function useSyncExternalStore<Snapshot>(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot,
): Snapshot;
パラメータ 説明
subscribe subscribeを開始する関数を渡す。戻り値には、「subscribeを解除する関数」を返す。ストアの状態通知関数としてonStoreChangeを受け取る。
getSnapshot ストアの値を返す関数。
getServerSnapshot Server Component用の引数。Serverまたはクライアントでのハイドレーション時にデータを返す関数。

例: 公式サンプル

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

function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
  // ...
}
let nextId = 0;
// 実際のTODOリストデータ(ストアデータ)
let todos = [{ id: nextId++, text: 'Todo #1' }];
// リスナーの管理
let listeners = [];

export const todosStore = {
  addTodo() {
    todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
    emitChange();
  },
  // 第一引数には、listener関数(onStoreChange)が渡されてくる
  subscribe(listener) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },
  getSnapshot() {
    return todos;
  }
};

function emitChange() {
  for (let listener of listeners) {
    // onStoreChangeを実行することで変更を通知できる
    listener();
  }
}
  • データの変更を通知する場合は、subscribe時に渡されるlistener関数を管理して値の変更を通知する必要がある

どういう場面で役に立つのか

外部ネットワークからサブスクライブ形式でデータ取得をする場合、これまでReactでは、useEffectuseState を組み合わせてこのような非同期処理を行うことが多かった。(useEffect内で購読データの更新を受け取りStateを更新する)

しかし、 useSyncExternalStore を使うことで、これらの非同期処理をReactのフックとして扱うことが可能になる。useEffectを使わないことで、データの取得とコンポーネントの状態更新をより直接的に結びつけることができるようになった。

React 18からは「Suspense」が登場し、レンダリングの途中でコンポーネントの処理を中断できるようになったため、useEffect を使わないデータ取得のアプローチが多くなってきている。この流れにのって脱useEffectデータフェッチを目指すプロジェクトでは、useSyncExternalStore を使ったデータ取得が有効なアプローチになる。

活用例1: Firestoreによるデータ取得

Subscribe形式のデータ取得の例として、リアルタイムデータベースのFirestore(Firebase)がある。

FirestoreでチャットアプリなどデータをSubscribeする場合、useEffectを使うと次のような形になる。

useEffectの例

const ChatComponent: React.FC<{ userUid: string }> = ({ userUid }) => {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  useEffect(() => {
    const reference = query(
      collection(getFirestore(), COLLECTION_NAME, userUid, SUB_COLLECTION_NAME),
    ).withConverter(ChatMessageFirestoreDataConverter);
    // データを購読してコールバック内で新しいデータを取得する
    const unsubscribe = onSnapshot(
      reference,
      (doc) => {
        const addedMessages: ChatMessage[] = [];
        doc.docChanges().forEach((change) => {
          // 実際は削除/更新などの処理も必要
          if (change.type === 'added') {
            addedMessages.push(...change.doc.data());
          }
        });
        setMessages([...messages, ...addedMessages]);
      },
    );
    // コンポーネントのアンマウント時に購読を解除
    return () => {
      unsubscribe();
    };
  }, [userUid]);

  return (
    <div>
      {messages.map((message, index) => (
        <div key={index}>
          {/* メッセージの内容を表示 */}
        </div>
      ))}
    </div>
  );
};

onSnapshotで、渡すコールバック内でFirestoreのデータが更新されるたびにuseStateの値を更新することで、再レンダリングを行なっている。

購読の解除は、useEffectのクリーンアップ関数で行っている。

useSyncExternalStoreの例

これを、useSyncExternalStoreを使って書き換えると次のようになる。

llet messages: ChatMessage[] = [];
const firebaseStore = {
   // subscribeに渡す関数
   subscribe(userUid: string): (onStoreChange: () => void) => () => void {
    return (onStoreChange) => {
      const reference = query(
        collection(getFirestore(), COLLECTION_NAME, userUid, SUB_COLLECTION_NAME),
      ).withConverter(ChatMessageFirestoreDataConverter);
      const unsubscribe = onSnapshot(
        reference,
        (doc) => {
          const addedMessages: ChatMessage[] = [];
          doc.docChanges().forEach((change) => {
            // 実際は削除/更新などの処理も必要
            if (change.type === 'added') {
              addedMessages.push(...change.doc.data());
            }
          });
          // ストアが変更されたことを通知
          onStoreChange();
          messages = [...messages, ...addedMessages];
        },        
      );
      return () => {
        // 購読解除時はメッセージも初期化する
        messages = [];
        // firebaseの購読を解除
        return unsubscribe();
      };
    };
  },
  // getSnapshotに渡す関数
  getSnapshot() {
    return messages;
  },
};

const ChatComponent: React.FC<{ userUid: string }> = ({ userUid }) => {
  const messages = useSyncExternalStore(
    useCallback(firebaseStore.subscribe(getAuth().currentUser!.uid), [getAuth().currentUser!.uid]),
    firebaseStore.getSnapshot,
  );
  return (
    <div>
      {messages.map((message, index) => (
        <div key={index}>
          {/* メッセージの内容を表示 */}
        </div>
      ))}
    </div>
  );
};

subscribeのコントロールは、useSyncExternalStoreが行うため、コンポーネントがアンマウントされると自動でunsubscribeされる。

firestoreが更新された時(onSnapshot内のコールバックよばれた時)は、onStoreChangeを呼び出すことで、再度レンダリングが走り、コンポーネントが更新される。

活用例2: ネットワーク監視(ブラウザAPIの制御)

ブラウザのAPIにて、windowに対してイベントリスナーを使用する場合(addEventListener/removeEventListener)でもuseSyncExternalStoreが利用できる。

以下は公式のサンプルとして紹介されていた、navigator.onLine というAPIから現在のネットワーク状況を監視するコード。

Reactのライフサイクル外のwindowイベントに対して、onStoreChangeを設定することで、useStateと同じ感覚で変更を監視できるようになる。

import { useSyncExternalStore } from 'react';

export default function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function getSnapshot() {
  return navigator.onLine;
}

function subscribe(onStoreChange) {
  window.addEventListener('online', onStoreChange);
  window.addEventListener('offline', onStoreChange);
  return () => {
    window.removeEventListener('online', onStoreChange);
    window.removeEventListener('offline', onStoreChange);
  };
}

まとめ

サブスクライブ形式やReactの領域外のイベント監視など、コールバック形式での値取得は、useEffectで対応しがちだったが、useSyncExternalStoreを使うこと効率的にhooks対応することができるようになった。

通常のAPIデータフェッチについては、SWR など、人気のライブラリを使うことで、キャッシュ管理含め安全にhook化できるため、これらと併用してuseEffectを進めていきたい。