🦔

useSyncExternalStore使ってる?

2024/09/07に公開

何かと使い方が分かりずらい(と個人的に思っている)useSyncExternalStoreですが、今回はその挙動を見てみます。Reactのソースコードを読んだとかそんな詳しいところまで追っていなくて挙動を整理する記事です。

ブラウザのwindow幅をReactに同期させる

まずは公式や他の記事でよく紹介されているブラウザのAPIとReactを同期させる例です。window幅を変えるとそれに反応してviewが更新され、値が増えたり減ったりするのが描画されます。

function getSnapshot() {
  return window.innerWidth;
}

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

  return () => window.removeEventListener("resize", callback);
}

const Root = () => {
  const innerWidth = useSyncExternalStore(subscribe, getSnapshot);

  return (
    <div>innerWidth: {innerWidth}</div>
  );
};

単純な例ですが、windowが変わったことを検知してReactのレンダリングを発火させている重要な例です。重要だと思うのでもう少し詳しく見ていきます。
window.innerWidthは普通に参照した場合、Reactによる再レンダリングがされないためwindow幅を変えたところでviewは更新されません。

const Root = () => {
  /* 初回の値が表示されるだけ */
  return <div>innerWidth: {window.innerWidth}</div>;
};

useSyncExternalStoreの一つ目の引数に渡しているsubscribe関数はuseSyncExternalStoreからcallbackを受け取っています。これをちょっとconsole.logで見てみます。

function subscribe(callback: () => void) {
  console.log("callback", callback);

  window.addEventListener("resize", callback);

  return () => window.removeEventListener("resize", callback);
}

いかにもコンポーネントを再レンダリングしてくれそうな関数ですね。

つまりsubscribe関数は、useSyncExternalStoreを実行しているコンポーネントを再レンダリングする関数(callback)をwindow幅が変わるたびに実行するようにしています。そしてgetSnapshotは単純に現在のwindow幅を返しているだけです。

挙動としては、widow幅が変わるたびにcallbackによりuseSyncExternalStoreを実行しているコンポーネントの再レンダリングを行い、再レンダリングが起きたタイミングでgetSnapshotにより現在のwindow幅を取得しています。再レンダリングが起きるとviewが更新されるため、そのタイミングでgetSnapshotで参照している値にviewが変わっています。
(どの例を見てもcallbackという名前になっているので分かりやすさを重視するならrerenderThisComponent(分かりやすい?)みたいな方がわかりやすいなと思いました。)
getSnapshotの面白い、便利な挙動があったのでもう一つ例を見てみます。

自作Storeを作成している例

useSyncExternalStoreを使って自作Storeを作っている記事を参考にしています。
このStoreの例で確認したいことは以下の二つです。

  1. グローバルステートの実現方法
  2. getSnapshotによる最適化

グローバルステートをどう実現しているのか?

グローバルステートはアプリケーション内のあらゆる場所に存在するコンポーネントから単一のstateを参照して、そのstateが更新されたら参照しているコンポーネントを全て再レンダリングさせる役割をもつかと思います。これを上記の自作Storeの例ではPub/Subで実装しています。

dispatchpublishersubscibesubsciberに相当するかと思います。subscribeonStoreChangeという関数を受け取ってそれをSetオブジェクトにaddしています。これは単純に配列にpushしているイメージかなと思います。dispatchstateを任意の値に変化させた後に、subscribeaddしていた関数を全て実行しています。これでsubscribeで登録していた関数をdispatchを実行したタイミングで全て実行する仕組みができました。

    state: initState(),
    storeChanges: new Set(),
    dispatch: (callback) => {
      context.state = callback(context.state);
      context.storeChanges.forEach((storeChange) => storeChange());
    },
    subscribe: (onStoreChange) => {
      context.storeChanges.add(onStoreChange);
      return () => {
        context.storeChanges.delete(onStoreChange);
      };
    },

この仕組みの上にReactコンポーネントを再レンダリングする仕組みをどう乗せているかという部分でuseSyncExternalStoreが登場します。
useSelectorは次のようになっています。

export const useSelector = <T, R>(getSnapshot: (state: T) => R) => {
  const context = useContext<ContextType<T>>(StoreContext);
  return useSyncExternalStore(
    context.subscribe,
    () => getSnapshot(context.state),
    () => getSnapshot(context.state)
  );
};

context.subscribeすることで記事の最初の例で確認したコンポーネントを再レンダリングする関数をSetオブジェクトにaddしています(getSnapshotについては後述します)。useSelectorしたコンポーネントの数だけそのコンポーネントを再レンダリングする関数がaddされることになります。
そしてuseDispatchは次のようになっています。

export const useDispatch = <T,>() => {
  const context = useContext<ContextType<T>>(StoreContext);
  return context.dispatch;
};

単純にcontext.dispatchを返しているだけです。useDispatchから受け取った関数は次のようにstateの値を変える関数を渡して実行されます。

  const dispatch = useDispatch<User>();

  const renameUser = () => {
    dispatch((prev) => {
      return { ...prev, name: getRandomName() };
    });
  };

これでdispatchが実行されるとstateの値が変わることに加え、各コンポーネントからaddされていた「コンポーネントを再レンダリングする関数」が全て実行されるようになりました。

getSnapshotによる最適化

もう一度useSelectorと合わせてコンポーネントでの使われ方を見てみます。getSnapshotは最初の例で示した通り、useSyncExternalStoreの一つ目の引数(コンポーネントを再レンダリングする関数)により再レンダリングが起きたタイミングで、getSnapshotにより現在のstateの値を取得します。useDispatchcontext.dispatchが実行されることで、stateの値が変わった後に再レンダリングが起きるため、更新後のstateの値を参照しています。
この例ではstateの値がオブジェクトであることを想定していますが、useSelectorに渡しているコールバック関数ではオブジェクト全部を返すわけではなくプロパティの値を返すようにしています。useSyncExternalStoreの二つ(三つ)目の引数にはオブジェクトからプロパティを絞って値を取得する関数を渡していることになります。

/* useSelectorの実装 */
export const useSelector = <T, R>(getSnapshot: (state: T) => R) => {
  const context = useContext<ContextType<T>>(StoreContext);
  return useSyncExternalStore(
    context.subscribe,
    () => getSnapshot(context.state),
    () => getSnapshot(context.state)
  );
};

/* コンポーネントでのuseSelectorの利用 */
const DisplayUserName = () => {
  const name = useSelector((state: User) => state.name);

  return (
    <div>
      <p>ユーザー名: {name}</p>
    </div>
  );
};

上記のようにgetSnapshotを実装することでコンポーネントでオブジェクトのstateを参照していても、参照しているプロパティに更新があった時のみコンポーネントを再レンダリングすることができます。
以下はボタンを押してユーザーの名前を更新する例ですが、名前を表示するDisplayUserNameコンポーネントだけが再レンダリングします。

import { useCallback } from "react";
import { StoreProvider, useDispatch, useSelector } from "./store";
import "./App.css";

type User = {
  id: number;
  isAdmin: boolean;
  name: string;
  email: string;
};

const initUser: User = {
  id: 1,
  isAdmin: true,
  name: "anonymous",
  email: "anonymous@anonymous.com",
};

function getRandomName() {
  const names = [
    "John",
    "Jane",
    "Alice",
    "Bob",
    "Charlie",
    "Dave",
    "Eve",
    "Frank",
    "Grace",
    "Hank",
  ];
  const randomIndex = Math.floor(Math.random() * names.length);

  return names[randomIndex];
}

const Button = () => {
  console.log("render ボタン");

  const dispatch = useDispatch<User>();

  const renameUser = useCallback(() => {
    dispatch((prev) => {
      return { ...prev, name: getRandomName() };
    });
  }, [dispatch]);

  return (
    <div>
      <button onClick={renameUser}>Rename</button>
    </div>
  );
};

const DisplayUserName = () => {
  console.log("render user name");
  const name = useSelector((state: User) => state.name);

  return (
    <div>
      <p>ユーザー名: {name}</p>
      {/* <DisplayCountDepthOne /> */}
    </div>
  );
};

const DisplayUserEmail = () => {
  console.log("render user email");

  const email = useSelector((state: User) => state.email);

  return (
    <div>
      <p>ユーザーのメールアドレス: {email}</p>
    </div>
  );
};

/*
  DisplayUserName, DisplayUserEmailは同じオブジェクトを参照するが、
  参照しているプロパティが更新された時のみ各々のコンポーネントが再レンダリングする
*/
const App = () => {
  return (
    <div className="App">
      <Button />
      <DisplayUserName />
      <DisplayUserEmail />
    </div>
  );
};

const Root = () => {
  return (
    <StoreProvider initState={() => initUser}>
      <App />
    </StoreProvider>
  );
};

export default Root;

ということでgetSnapshotから返される値が変わらなかった場合は再レンダリングがスキップされる例でした。

まとめ

useSyncExternalStore公式のReferenceでも解説されていますが、外部ストアへsubscribeするという表現がピンと来なかったため挙動を確認しました。Reactのライフサイクルに乗らない値が更新されたタイミングでコンポーネントの再レンダリングをして、viewにも値を反映できるようにするということかなと思います。Reactのライフサイクルに乗らないけど乗せたい状況は意外と多いと思うのため活用できる場面は割と多そうだと思いました。

Discussion