LocalStorage で useSyncExternalStore の動きを理解する


多くのフレームワークが、日々アップデートされています。新しい機能が提供されたり、不具合にパッチがあてられて修正されたり、いままで困難だった実装が簡単になったり、より高度な開発ができるように常に進化を続けています。

Gaji-Labo が得意とするReactなどはその最たるもので、価値あるプロダクトを創り出すためにはその進化へのキャッチアップを欠かすことができません。

この記事では、先日知り得た useSyncExternalStore というAPIを実例を通して理解していきたいと思います。

useSyncExternalStore とは

useSyncExternalStore は React 18 から導入されたフックで、外部のストアとの同期を効率的に行うことができます。ライブラリ開発者向けの機能として提供されはじめ、Zustand などの状態管理ライブラリでも内部実装で採用されているAPIです。

このフックは外部ストアの状態をスナップショットとして取得し、ストアを購読(subscribe)します。外部ストアの状態が更新されたタイミングでコンポーネントは再レンダーされ、最新のスナップショットが取得されます。

この説明でピンときますか?わたしはピンときませんでしたので、ここからは使い方や具体例を紹介しながらイメージしていきましょう。

基本的な使い方

まずは基本的な使い方を交えて理解を進めていきます。 useSyncExternalStore は3つの引数を受け取り、任意の型の値をスナップショットとして返します。

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

subscribe (第一引数)

ストアを購読(subscribe)する関数を渡します。

function subscribe (callback) {
  const unsubscribe = store.subscribe(callback);
  return unsubscribe;
}

関数に渡される callback は状態が更新されたタイミングで呼ばれます。また、 subscribe 関数は購読をクリーンアップする処理を関数として返します。( useEffect の返り値のように!)

getSnapshot(第二引数)

最新のスナップショットを返す関数を渡します。

function getSnapshot () {
  return store.getState();
}

getServerSnapshot (第三引数・オプショナル)

第二引数と同様にスナップショットを返す関数ですが、こちらはSSRおよびハイドレーション時に呼ばれます。

function getServerSnapshot () {
  return INITIAL_STATE;
}

LocalStorage の実装で動きを理解する

もう少し理解を深めるためにlocalStorageのデータを扱う useLocalStorageState フックを習作してみます。

公式ドキュメントではブラウザAPIへのサブスクライブを例としてあげていましたが、 useSyncExternalStore はその名のとおり外部ストアとの同期のためにあります。localStorageはブラウザAPIであると同時に外部ストアであると言えるので、理解の助けになる例示になるのではないかと考えました。

どう使いたいか

まずこの機能をどのように使いたいか、インターフェースから決めましょう。この新しいフックは、 useState に近い形で localStorage のデータを読み書き出来るように実装します。

const [values, setValues] = useLocalStorageState("values", []);

setValues(["foo", "bar"]);
setValues((pre) => {
  return [...pre, "baz"];
});

第一引数にはlocalStorageのキー( "values" )を、第二引数には初期値( [] )を渡します。

subscribe

subscribe 関数から準備しましょう。これは依存を持たないのでフックの外に作ります。

const EVENT_LOCAL_STORAGE_CHANGE = "localstoragechange";

function subscribe(callback: () => void) {
  window.addEventListener(EVENT_LOCAL_STORAGE_CHANGE, callback);
  return () => {
    window.removeEventListener(EVENT_LOCAL_STORAGE_CHANGE, callback);
  };
}

localStorage は値を変更しても何のイベントも発火しないため、ここでは localstoragechange という独自のイベントを創作します。もちろん、値を変更するタイミングで自前で dispatchEvent で発火してあげなければいけません。値が更新されてイベントが発火すると、イベントリスナに登録した callback が呼び出される仕組みです。

useLocalStorageState

フック本体はこのように実装してみました。

parseJson の実装は割愛しますが、 JSON.parsetry でラップしただけの関数です。パースに失敗した場合は null を返却します。

export function useLocalStorageState<T>(key: string, initialValue: T) {
  const initialValueString = JSON.stringify(initialValue);
  const getSnapshot = () =>
    window.localStorage.getItem(key) || initialValueString;
  const getServerSnapshot = () => initialValueString;
  
  const dataString = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
  
  const data = parseJson(dataString);
  const setData = (newData: T | ((prev: T) => T)) => {
    const nextData =
      typeof newData === "function"
        ? (newData as (prev: T) => T)(data)
        : newData;
    window.localStorage.setItem(key, JSON.stringify(nextData));
    window.dispatchEvent(new Event(EVENT_LOCAL_STORAGE_CHANGE));
  };

  return [data, setData];
}

ひとつめのポイントは、データを文字列で扱っているところです。これは localStorage が文字列の値しか扱えないためですが、「 getSnapshot でパースした結果のデータを渡した方がシンプルでは?」というアイデアが浮かびます。例えばこのように。

const getSnapshot = () => parseJson(window.localStorage.getItem(key)) || initialValue;

しかしこの実装はうまくいきません。 getSnapshot が返すオブジェクトが前回と異なる場合、Reactはコンポーネントを再レンダーします。 JSON.parse でパースされると毎回新しいオブジェクトが作られるため、「別のオブジェクトだ」と判別されて再レンダーが延々と繰り返され、無限ループに陥ります。文字列同士であれば同一のオブジェクトだと判断されるのでこれを回避できます。

もうひとつのポイントは、先に述べたカスタムイベントです。 setData が呼ばれて localStorage の値を更新したあとで localstoragechange イベントを発火します。

window.dispatchEvent(new Event(EVENT_LOCAL_STORAGE_CHANGE));

Reactで開発をするなかでカスタムイベントが活躍する機会はそう多くはありませんが、独自のイベントを自由に発火できる dispatchEvent はひょんなところで役立ちます。

useSyncExternalStore で実装する利点

上で習作したような機能は、従来 useStateuseReducer などで実装されてきたと思います。今回のように useSyncExternalStore で実装することになにか優位性はあるのでしょうか?

ティアリングの防止

ティアリング(tearing)は、Reactのレンダリングプロセス中に状態が部分的に更新されることで、UIが不整合な状態になり表示崩れやちらつきが発生する現象のことです。

useSyncExternalStore は外部ストアの状態変化をコンポーネントに同期させ、このティアリングの発生を抑制することができます。

クライアント側での動作が保証されている

Next.js などで開発中、 window を参照しようとしてサーバーサイドでエラーになった経験はないでしょうか。そして useEffect などでラップしてクライアント側でのみ実行させて回避したことはないでしょうか。

useSyncExternalStore の第一・第二引数の関数は、クライアント側でのみ実行されます。外部ストアは通常クライアント側でのみ利用可能なもので、その状態購読やデータの取得のための関数はクライアント側でのみ実行されるというのが道理です。また、公式のリファレンスにある例window オブジェクトを参照していることからも、間違いなさそうです。

ブラウザAPIを活用する際にも、 useEffect などでラップする必要はありません。

おわりに

Reactの進化の速度はすさまじく、常に最新情報を完全に把握し続けることは難しいです。ですが、アンテナを張り、有用な情報をキャッチできるように、鮮度の高い情報をしっかり購読(subscribe)していきたいですね。

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は新規事業やサービス開発に取り組む、事業会社・スタートアップへの支援を行っています。

弊社では、Next.js を用いた Web アプリケーションのフロントエンド開発をリードするフロントエンドエンジニアを募集しています!さまざまなプロダクトやチームに関わりながら、一緒に成長を体験しませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください!

求人応募してみる!


投稿者 Oikawa Hisashi

フロントエンドエンジニア。モダンなJavaScript開発に関心があります。 デザインからバックエンドまで網羅的にこなすマルチデザイナーとして長く活動してきた経験を活かして、これから関わる様々なものをデザインしていきたいです。チームもコミュニケーションもデザインするもの。ライフワークはピアノと水泳。