ReactのuseSyncExternalStoreについて

目次

こんにちは、Iwataです。

みなさん、React の useSyncExternalStore はご存知でしたでしょうか?意外と知られてないかなと思い、今回はこの useSyncExternalStore について紹介していきます。

useSyncExternalStore とは

useSyncExternalStore は React18 で追加された React Hook で、外部ストアをサブスクライブできるようにします。React の管理外のデータを React に連携させるために使います。

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

これは、ストアにあるデータのスナップショットを返します。引数として 2 つの関数を渡す必要があります: (#1)subscribe 関数はストアへのサブスクライブを開始します。サブスクライブを解除する関数を返す必要があります。getSnapshot 関数は、ストアからデータのスナップショットを読み取る必要があります。

注意点としては useState や useReducer でことたりるようであれば、そちらを推奨である点です。こちらは公式 doc にも注意書きとして記載されています。

なぜこの API が登場したのか調べていくと、「tearing」という言葉にたどり着きました。「テアリング」とは、グラフィックや UI で視覚的な不一致を指す用語です。JavaScript はシングルスレッドであるため、この問題は一般的には Web 開発には影響しませんでした。しかし、React 18 では startTransition や Suspence が登場し、「並行レンダリング(concurent rendering)」が可能となり、この問題が発生する可能性があります。この外部のストアとの同期時に tearing を防ぐ解決策の一つとしてこの useSyncExternalStore ができました。厳密にはすでに基礎となる実装は存在していてその Hooks の名前がの useSyncExternalStore となったそうです。(#2)

内部的にはセレクタ(スナップショット用)が変更されるたびに外部ソースに再度購読するのではなく、セレクタの結果となる値を比較して、スナップショットを再度取得するかどうかを決定します。

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

ブラウザのイベントをサブスクライブして、その値を同期させることができます。次の例を見てみましょう。次の例ではブラウザのウィンドウサイズが変わった時にそれを検知して反映させるコードです。

import * as React from "react";
const subscribeWindowSizeChange = (callback: () => void) => {
  window.addEventListener("resize", callback);
  return () => {
    window.removeEventListener("resize", callback);
  };
};
const getWindowWidth = () => {
  return window.innerWidth;
};
const getWindowHeight = () => {
  return window.innerHeight;
};

export const useWindowSize = () => {
  const width = React.useSyncExternalStore(
    subscribeWindowSizeChange,
    getWindowWidth
  );
  const height = React.useSyncExternalStore(
    subscribeWindowSizeChange,
    getWindowHeight
  );

  return {
    width: width,
    height: height,
  };
};

このように useSyncExternalStore を使ってブラウザのイベントをカスタムフック useEffect を使わずに定義することができます。

独自の State 管理

useSyncExternalStore を使って React.useEffect や React.useState を使わずに state を管理することができます。簡易的な state 管理の hooks を作成して、React.useState を使った場合と比較できる例を次のように作りました。

Installing editor…

例としては input field を用意して、そこに値が入力されたらその値が別のコンポーネントに同期されるというものです。

React.useState を使った例では React の Context を使い、Provider 配下に state を共有します。次のコードが useState で管理した ContextProvider の例です。

type CustomStore = {
  displayName: string;
  email: string;
};
type FieldName = "displayName" | "email";

type ContextValues = {
  store: CustomStore;
  updateStore: (values: Partial<CustomStore>) => void;
};

const Context = React.createContext<ContextValues>({
  store: {
    displayName: "",
    email: "",
  },
  updateStore: () => undefined,
});

const useContext = () => React.useContext(Context);

type ContextProviderProps = {
  children: React.ReactNode;
};
const ContextProvider: React.FC<ContextProviderProps> = (props) => {
  const [state, setState] = React.useState<CustomStore>({
    displayName: "",
    email: "",
  });
  const contextValue: ContextValues = {
    store: state,
    updateStore: React.useCallback((values) => {
      setState((prev) => ({ ...prev, ...values }));
    }, []),
  };
  return <Context.Provider value={contextValue} children={props.children} />;
};

もう一方では React.useRef と useSyncExternalStore を使って state を管理し、context で state を共有している例です。useRef のみだと差分を検知できないため、subscriber を作り、state を更新する際に callback を呼び出すようにします。

今回の例では useRef を使ったコードにしていますが、これが、独自のデータを管理する独自関数を定義して useSyncExternalStore を使って state を同期させることもできます。

type UseStoreValuesActions = {
  getSnapshot: () => Store;
  updateStore: (value: Partial<Store>) => void;
  subscribe: (callback: () => void) => () => void;
};

function useStoreValues(): UseStoreValuesActions {
  const store = React.useRef(initialState);

  const getSnapshot = React.useCallback(() => store.current, []);

  const subscribers = React.useRef(new Set<() => void>());

  const updateStore = React.useCallback((value: Partial<Store>) => {
    store.current = { ...store.current, ...value };
    subscribers.current.forEach((callback) => callback());
  }, []);

  const subscribe = React.useCallback((callback: () => void) => {
    subscribers.current.add(callback);
    return () => subscribers.current.delete(callback);
  }, []);

  return {
    getSnapshot,
    updateStore,
    subscribe,
  };
}

const StoreContext = React.createContext<UseStoreValuesActions | null>(null);

function Provider({ children }: { children: React.ReactNode }) {
  const values = useStoreValues();
  return (
    <StoreContext.Provider value={values}>
              {children}     {" "}
    </StoreContext.Provider>
  );
}

function useStore<SelectorOutput>(
  selector: (store: Store) => SelectorOutput
): [SelectorOutput, (value: Partial<Store>) => void] {
  const store = React.useContext(StoreContext);
  if (!store) {
    throw new Error("Store not found");
  }

  const state = React.useSyncExternalStore(store.subscribe, () =>
    selector(store.getSnapshot())
  );

  return [state, store.updateStore];
}

2 つの例を紹介しました。ブラウザの devtool で Components タブから render されたコンポーネントをハイライトするようにできるのでそれを使って確認していきます。

useState では一つの field の state だけを更新するとその配下の Component が全てレンダーされることがわかります。一方でもう一つの例では変更した state に関連する Component だけレンダーされることがわかります。

Installing editor…

おわりに

React の useSyncExternalStore について紹介しました。useSyncExternalStore を使って簡易的な state 管理の Hooks を作ってみました。普段使う state 管理ライブラリを使っているとあまり触ることのない useSyncExternalStore でしたが、状況によっては使えそうだなと感じました。少しでも参考になれば幸いです。

参考文献

#1 https://ja.react.dev/reference/react/useSyncExternalStore#usesyncexternalstore

#2 https://blog.logrocket.com/exploring-react-18-three-new-apis/

https://react.dev/reference/react/useSyncExternalStore

https://react.dev/learn/you-might-not-need-an-effect#subscribing-to-an-external-store

https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api