🖥

【React】matchMedia で理解する useSyncExternalStore

2022/12/28に公開

React の API で、よくわからないしわかる必要性もあんまりない(かもしれない) React Hooks に useSyncExternalStore があります。Redux のように React 外で管理されているステートオブジェクトを React にインテグレートするためのフックということくらいは耳にしたことがあるのではないでしょうか。

そのフックの機能や使い方から主にステート管理ライブラリ開発者向けに用意されていると考えられます。ライブラリ開発者向け API ならアプリレイヤーの開発者には関係ないのではと思われるかもしれません。でも使い方を知っていれば、何か応用する案を思いつくこともあるでしょう。実際、 useSyncExternalStore はブラウザ API との統合にも使うことができるとドキュメントで紹介されています。

この記事では useSyncExternalStorematchMedia を組み合わせたカスタムフック useMatchMedia を作ることで、使い方を理解していきたいと思います。

結論

import { useCallback, useMemo, useSyncExternalStore } from "react";

export function useMatchMedia(
  mediaQuery: string,
  initialState = false
): boolean {
  const matchMediaList = useMemo(
    () =>
      typeof window === "undefined" ? undefined : window.matchMedia(mediaQuery),
    [mediaQuery]
  );

  const subscribe = useCallback(
    (onStoreChange: () => void) => {
      matchMediaList?.addEventListener("change", onStoreChange);
      return () => matchMediaList?.removeEventListener("change", onStoreChange);
    },
    [matchMediaList]
  );

  return useSyncExternalStore(
    subscribe,
    () => matchMediaList?.matches ?? initialState,
    () => initialState
  );
}

動作確認できる codesandbox 埋め込みは下のほうにあります。

matchMedia とは

useSyncExternalStore 以前に matchMedia が何者なのかわからないと理解できないので簡単に説明しましょう。

ブラウザの Web API のひとつで CSS のメディアクエリで判定できることを JavaScript でもできるようにする API です。

https://developer.mozilla.org/ja/docs/Web/API/Window/matchMedia

window.matchMedia(query) によって MediaQueryList オブジェクトを生成し、その .matches プロパティを確認することで指定したクエリにマッチしているかを判定できます。

const mediaQueryList = window.matchMedia("(min-width: 599px)");
console.log(mediaQueryList.matches); // true or false

CSS のメディアクエリの書き方を知っていれば、非常にわかりやすい API になっていますね。

MediaQueryList のパワフルなところは、判定結果の変化を検知できることです。

// 画面サイズが 599px を前後するたびにログが記録される
mediaQueryList.addEventListener("change", () => {
  console.log(mediaQueryList.matches);
});

これも JavaScript によくあるイベントリスナーの形式なので直感的ですね。もちろんリスナー解除の removeEventListener も用意されています。

useSyncExternalStore とは

https://beta.reactjs.org/reference/react/useSyncExternalStore

Redux のように React 外で管理されているステートを React コンポーネントで購読できるようにするフックです(上のドキュメントはベータ版サイトです)。

React コンポーネントは基本的に React が管理している props, state, context 以外のデータを読むことができません(適当なグローバル変数を参照することはできますが、その値が変化してもコンポーネントが再レンダリングできません)。それだと React 非依存の独自ステートを持つライブラリなどとの統合が難しくなります。それを解決するために用意されたのが useSyncExternalStore です。

useSyncExternalStore がなかった時代は useStatesetState を強制再レンダリング関数とみなし、外部ステートの変更検知イベントに setState を仕込むような書き方がされていました。ただしそれは React が意図した使い方ではなく、React に Transition の概念が導入されて破綻しました(参考)。useSyncExternalStore は言わば避難ハッチとして実装された経緯があります。

インターフェイスは次のようになっています。

export function useSyncExternalStore<Snapshot>(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot
): Snapshot;

第 1 引数の subscribeonStoreChange 関数を受け取って関数を返す関数になっています(超わかりにくい)。名前の通り対象の外部ステートの変更を購読する処理を渡す口です。外部ステートが変化したら onStoreChange を実行することで React に再レンダリングを依頼することができます。返す関数は外部ステートの購読を終了するために実行するクリーンアップ関数です(useEffect みたいですね)。

第 2 引数の getSnapshot は外部ステートの値を取得するための関数です。ステートは同一性を担保する必要があり、常に別のオブジェクトを返すような次のような関数を渡すと不具合に繋がります。

const getSnapshot = () => {
  return { foo: "bar" };
};

第 3 引数の getServerSnapshot は Server Side Rendering 時のステートを決定する関数です。オプショナルですが Next.js のような SSR/SSG フレームワークが主流の現代ではほぼ指定必須でしょう。渡していないのにサーバーサイドで実行されるとエラーを吐きます。

useMatchMedia を実装する

言葉では useSyncExternalStore の使い方をイメージしにくいと思います。matchMediauseSyncExternalStore を組み合わせて、宣言的にメディアクエリを指定できるカスタムフック useMatchMedia を作ってみましょう。

実装する関数インターフェイスは次のようなイメージです。

function useMatchMedia(mediaQuery: string, initialState = false): boolean;

initialState はメディアクエリ判定ができない、つまりサーバーサイドでコードが実行されるときに使用される想定です。

このようなカタチをしたカスタムフックなら、次のように簡単かつ宣言的に使うことができるでしょう。

const MyApp: FC = () => {
  const isMobileSize = useMatchMedia("(max-width: 599px)");

  return <p>{isMobileSize ? "mobile" : "not mobile"}</p>;
};

それでは順番に作っていきます。

useMemoMediaQueryList オブジェクトを保持する

const matchMediaList = useMemo(
  () =>
    typeof window === "undefined" ? undefined : window.matchMedia(mediaQuery),
  [mediaQuery]
);

window.matchMedia を実行するたびに新しい matchMediaList オブジェクトが生成されます。それでは勝手が悪いので引数の mediaQuery が変化した時だけ再生成されるように useMemo で括っておきます。

SSR のときは window オブジェクトが存在しないので 存在チェックもしておきましょう。

subscribe 関数を定義

const subscribe = useCallback(
  (onStoreChange: () => void) => {
    matchMediaList?.addEventListener("change", onStoreChange);
    return () => matchMediaList?.removeEventListener("change", onStoreChange);
  },
  [matchMediaList]
);

先程宣言した matchMediaList オブジェクトを使って、 useSyncExternalStore の第 1 引数となる subscribe 関数を定義します。subscribe 関数の引数には React に再レンダリング依頼をするための onStoreChange 関数を渡してもらえるので、それをメディアクエリの判定結果が変わったタイミングで発火します。そのタイミングを捕捉できるのはもちろん addEventListener で登録する change イベントです。onStoreChange 関数をそのまま addEventListener に渡します。

subscribe 関数からは外部ステートの購読を停止する時に呼ぶクリーンアップ関数の返却が求められます。addEventListener で開始した購読は、removeEventListener で解除することが可能です。戻り値のクリーンアップ関数内で removeEventListener を実行しましょう。

useSyncExternalStore に渡す subscribe 関数は参照が同一であることが大切です。useSyncExternalStore は再レンダリング時に subscribe 関数を Object.is で比較して、異なるときに前回のクリーンアップ関数を発火します。つまり再レンダリングのたびに違う subscribe 関数を渡すと、購読開始と購読解除を何度も実行することになります。引数や他のステートに依存しないのであれば、useMatchMedia の関数スコープの外で subscribe 関数を宣言すればいいですが、今回は引数に依存するため useCallback で参照同一を担保します。matchMediaListuseMemo で括ったのも subscribe 関数を固定するためです。(パフォチュ以外に useMemo or useCallback を使っていいんだっけなと思いつつも)この方法はドキュメントにも記載されていました。

https://beta.reactjs.org/reference/react/useSyncExternalStore#my-subscribe-function-gets-called-after-every-re-render

useSyncExternalStore で購読を開始

return useSyncExternalStore(
  subscribe,
  () => matchMediaList?.matches ?? initialState,
  () => initialState
);

いよいよ本題の useSyncExternalStore を使います。

第 1 引数は 用意しておいた subscribe 関数です。再びの言及ですが、参照が無駄に変化しないような渡し方をしてください。

第 2 引数は外部ステートとなる値を取得する関数です。今回外部ステートとみなしているのは matchMediaList.matches です。それを return するように関数を書いておきます。一応 matchMediaListundefined の可能性があるので、オプショナルチェーンと null 合体演算子で必ず boolean 値が返るようにしておきます。subscribe とは異なり第 2 引数に渡す関数それ自体は参照を安定させる必要はないですが、その戻り値は React ステートとして扱われるので、やはり Object.is で比較して変化したときに再レンダリングされることを念頭に置いてください。毎回新しいオブジェクトが返されるような書き方では無限ループになり得ます。

第 3 引数は SSR 時(SSG 含む)とそれを hydrate するときにだけ使用される関数です。どのみち クライアントサイドまで到達しないとメディア判定はできませんので、initialState を固定で返しておきます。そう言った意味では引数の initialStatevalueOnSSR みたいな変数名でもいいかもしれません。

動かしてみる

画面サイズを判定するメディアクエリで試してみましょう。せっかくなので新しい Range Syntax のメディアクエリを使ってみます(動かないブラウザがあります。手元の Safari はだめでした。caniuse 参考)。

export default function App() {
  const isMobileSize = useMatchMedia("(width < 600px)");
  const isTabletSize = useMatchMedia("(600px <= width <= 1024px)");
  const isDesktopSize = useMatchMedia("(1024px < width)");

  return (
    <div className="App">
      <p>
        isMobileSize: <b>{String(isMobileSize)}</b>
      </p>
      <p>
        isTabletSize: <b>{String(isTabletSize)}</b>
      </p>
      <p>
        isDesktopSize: <b>{String(isDesktopSize)}</b>
      </p>
    </div>
  );
}

上の埋め込み要素で [Open Sandbox] をクリックすれば新規タブで codesandbox のプレビューが開きます。PC の方はブラウザサイズをグリグリして試してみてください。スマホやタブレットでご覧の方はデバイスの方向を変えて画面幅を変化させてみてください。いずれかの値が falsetrue を行き来していれば正しく動作しています。

以上で実装は終了です!「意外と使い所がありそう!」と感じていただけたら本望です。

注意点

これもドキュメントで言われていることですが、 useStateuseReducer でできることはそちらを優先して使ってください。useSyncExternalStore はその名の通り外部のデータストアと同期を取ることを目的としたフックです。あなたが新しいステートを用意するときに、わざわざ React の管理外にステートを宣言する必要はありません。ちょっとしたグローバルステートがほしいのであれば useState で宣言したものを Context で配信するなどで対応しましょう。もしくは Recoil 使いましょう。

まとめ

useMatchMedia フックを作ることで useSyncExternalStore の使い方を紹介しました。

第 1 引数の subscribe 関数の参照や getSnapshot の戻り値の同一性には注意してください。
useState でできることをわざわざ useSyncExternalStore でやる必要はありません。まず useState でできるか検討して下さい。
でも useSyncExternalStore の使い所は意外と多そうです。色々用途を考えてみてください。ぜひそれを教えてください。

それではよい React ライフを!

GitHubで編集を提案

Discussion