👁️‍🗨️

useSyncExternalStoreのためにObservableMapを実装する

2022/10/03に公開

はじめに

React v18から useSyncExternalStore という Hooks が追加されました。
https://ja.reactjs.org/docs/hooks-reference.html#usesyncexternalstore

外部データソースからのデータ読み出しやデータの購読に推奨される Hooks との説明があります。
この外部データソースというのは、例えば React の状態管理対象外の Global、もしくは Module に保持している値も含まれるのではと考えました。

そこで本記事では Module Scope 内で保持している Map Object を Observable にし、useSyncExternalStore で監視できるようにしてみます。

実際のコード

ObservableMap の例

例えばこんな感じの実装になるでしょうか。

type MapEventDetail<K, V> =
  | {
      type: "delete";
      key: K;
    }
  | {
      type: "set";
      key: K;
      value: V;
    };

export class ObservableMap<
  K extends string | number = string,
  V = any
> extends EventTarget {
  private _map: Map<K, V>;
  constructor(args?: ConstructorParameters<typeof Map<K, V>>) {
    super();
    if (args === undefined) {
      this._map = new Map();
    } else {
      this._map = new Map<K, V>(args[0]);
    }
  }

  delete(key: K): boolean {
    const result = this._map.delete(key);
    this.dispatchEvent(
      new CustomEvent<MapEventDetail<K, V>>("delete", {
        detail: {
          type: "delete",
          key,
        },
      })
    );
    return result;
  }

  get(key: K): V | undefined {
    return this._map.get(key);
  }

  set(key: K, value: V): this {
    this._map.set(key, value);
    this.dispatchEvent(
      new CustomEvent<MapEventDetail<K, V>>("set", {
        detail: {
          type: "set",
          key,
          value,
        },
      })
    );
    return this;
  }

  get size(): number {
    return this._map.size;
  }
  
  // TODO: clear などを実装していないので、必要であれば実装する
}

EventTarget インタフェースを実装することで、イベントに関する3つのメソッドである

  • addEventListener()
  • removeEventListener()
  • dispatchEvent()

が利用可能になります。
Map の中身を変化させる deleteset 内で dispatchEvent を発火すると、外部から変化されたことを検知できる、という流れです。

これを React 側から使ってみましょう。

useSyncExternalStore との連携例

例えば外部から Resource を fetch して、Module 内の Map Object にキャッシュするみたいなことをしてみましょう。

import { useEffect, useSyncExternalStore } from "react";
import { ObservableMap } from "./ObservableMap";

const cacheMap = new ObservableMap<
  string,
  | { isLoaded: false }
  | { isLoaded: true; resource: unknown; }
>();

const subscribe = (onStoreChange: () => void) => {
  cacheMap.addEventListener("set", onStoreChange);
  cacheMap.addEventListener("delete", onStoreChange);
  return () => {
    cacheMap.removeEventListener("set", onStoreChange);
    cacheMap.removeEventListener("delete", onStoreChange);
  };
};

export const useResource = (resourceUrl: string) => {
  const resource = useSyncExternalStore(subscribe, () =>
    cacheMap.get(resourceUrl)
  );

  useEffect(() => {
    const mapKey = resourceUrl;
    const prev = cacheMap.get(mapKey); // 多重fetch防止
    if (prev !== undefined) {
      return;
    }

    cacheMap.set(mapKey, { isLoaded: false });

    fetch(resourceUrl)
      .then((v) => // doSomething)
      .then((v) => {
        cacheMap.set(mapKey, {
          isLoaded: true,
          resource: v
        });
      });
  }, [resourceUrl]);

  return resource ?? { isLoaded: false };
};

Resource の読み込みを開始するタイミングと、読み込みが完了したタイミングでキャッシュを行う Map Object に値を set しています。
これにより値が Map Object に代入され、またそれと同時に追加されたイベントが発火します。
それをトリガに useSyncExternalStore からの戻り値が新たな Snapshot として更新され、呼び出し元から参照できる様になりました。

このキャッシュ機構のメリットとしては、useRef や useState で値を保持して、同一の呼び出し元からであればキャッシュした値を返せていたことが、Module 内に定義してある Map Object にどんどん突っ込んでいくことで異なる呼び出し元からの要求でもキャッシュから返せるようになることでしょうか。

今までも例えば useState で関係ない値を代入して forceRefresh として扱うことでも似たようなことが出来ましたが、呼び出し元に値を返していなかったり、呼び出し元が Promise を throw する Suspense 状態になった場合、ユーザ起因のイベントなどが起こるなどの再レンダリング要求が起こらない限り、React の状態管理外の値を返す様な Hooks は実装にもよりますが正しく値を返せないことがありました。
それらが解決されたのは非常に嬉しいことです。

おわりに

とは言えこうも思われるでしょう。
React Queryでいいのでは?と。おそらくその感覚は間違ってはいないと思います。
依存ライブラリを増やしたくない、などのケースに useSyncExternalStore や、紹介した ObservableMap などを思い出していただければ幸いです。

ただ今回紹介したコードは Event 周りの補完が効かないものとなっています。
これは EventTarget を継承したクラスを Generics 対応し、そのクラスを ObservableMap で使うようにすると解決する内容ではあるので、興味のある方は試してみてください。

実際に試したコードが以下になります

https://github.com/yamachu/pokedex-net-webassembly-without-blazor/pull/31/files

Discussion