🪶

TanStack Query みたいな非同期状態管理ツールを試作してみた

2025/02/02に公開

久しぶりに2weekコンタクトを2week使い切る前に無くして落ち込んでいる桐澤です。今回Reactで動く非同期状態管理ツールを試作しました。

モチベーション

ReactでSPA(ここで言うSPAはSSRするサーバーがなくビルドしたJS、CSSを配布するだけのアプリケーション)を開発する際にTanStack Queryなどのツールを使ってサーバーステートを管理するのが一般的かと思います。今回はSPAにおいてデータフェッチ管理をツールを使わずに頑張るにはどうすれば良いのかなと思ったことが発端となりました。
useEffectを使ったデータフェッチは公式のリファレンスにも記載されています。しかし欠点があることも示しておりツールを使うことをおすすめしています。
React19から使えるようになったuseSuspenseを使った方法もSuspneseのリファレンスで紹介されています。しかしこちらもフレームワークを通して使うことを推奨しているようで自前で使うことはサポートしていないようです。

使い方の規約のある (opinionated) フレームワークを使用せずにサスペンスを使ったデータフェッチを行うことは、まだサポートされていません。サスペンス対応のデータソースを実装するための要件はまだ不安定であり、ドキュメント化されていません。データソースをサスペンスと統合するための公式な API は、React の将来のバージョンでリリースされる予定です。

今後1,2年はTanStack Queryなどのツールを使っていくことがベストかなと思います。ただ今回のテーマは「SPAにおいてデータフェッチ管理をツールを使わずに頑張るにはどうすれば良いのかな」なので試作することにしました。

ソースコード

https://github.com/kirikirisu/mini-query

実装する機能

サーバーステートを扱う際に欲しい以下の機能をある程度再現してみます。(完全再現ではなく大枠だけ再現)

  • サーバーから取得したデータと取得時のステータスをReactのStateとして扱える
  • サーバーから取得したデータを任意のタイミングで再取得できる
  • preloadとかprefetchみたいなやつ

実装

状態管理ツールを作る際にPub/Subパターンがよく使われます(自作Storeを作った時の例)。今回もこのパターンを使ってJSのクラスで管理しているデータとReactを連携させます。
まずはObserverを作ります。queryとmutationでAPIフェッチの際のオプションを微妙に変えたかったのでbase-observer.tsとしていますが大体同じです。

https://github.com/kirikirisu/mini-query/blob/main/packages/core/src/base-observer.ts#L1-L43

部分的に見ていきます。
stateにはReactでstateとして扱いたい(この値が変わったらUIも更新したい)と思うデータを定義ておきます。subscribersにはReactコンポーネントを再レンダリングさせる関数が入る想定で、fetcherはAPIリクエストを行う非同期関数が入る想定です。

  private subscribers = new Set<() => void>();
  protected state: State;
  protected fetcher?: () => Promise<unknown>;

以下のメソッドは前述したsubscribersにReactコンポーネントを再レンダリングさせる関数(rerender関数?)を追加するsubscribeメソッドとsubscribersに存在するrerender関数を片っ端からすべて実行するメソッドです。
これでPub/Subの仕組みが完成しました。

  subscribe(onStoreChange: () => void) {
    this.subscribers.add(onStoreChange);

    return () => {
      this.subscribers.delete(onStoreChange);
    };
  }

  notify() {
    this.subscribers.forEach((onStoreChange) => onStoreChange());
  }

Reactと連携するためにメソッドを追加します。dispatchはcallback関数によってstateの値を変えます。stateの値を変えたらnotifyも実行して全てのrerender関数を実行します。getStateはシンプルに現在のインスタンスに存在するstateを返すだけです。callback関数でstateの値をどう変異させるか?とかsubscribersにどうやってrerender関数を登録するかとかは後述します。

  dispatch<T>(callback: (state: State<T>) => State<T>) {
    this.state = callback(this.state as State<T>);
    this.notify();
  }

  getState<K>(): State<K> {
    return this.state as State<K>;
  }

最後に非同期関数を実行するための飾りをつけます。
setFetcherはPromiseを返す関数をfetcherにセットします。

  setFetcher<T>(fetcher: () => Promise<T>) {
    this.fetcher = fetcher;
  }

  abstract load(): Promise<void>;

loadの実装は以下の通りです。

https://github.com/kirikirisu/mini-query/blob/main/packages/core/src/query-observer.ts#L8-L40

setFetcherで登録した非同期関数を実行しますが、最終的に「データ取得時のステータスをReactのStateとして扱いたい(非同期関数実行時のステータスが変わったらUIにも反映させたい)」ため、ステータスが変わるタイミングで前述したdispatchを実行します。
これでloadメソッドを実行したら任意の非同期関数を実行、かつ非同期関数のステータスに合わせてstateの値が変わり、その都度subscribersに登録されているrerender関数が全部実行されるようになりました。

    try {
      this.dispatch((state) => {
        return {
          ...state,
          isFetching: true,
        };
      });

      const response = await this.fetcher();

      this.dispatch((state) => {
        return {
          ...state,
          isFetching: false,
          data: response,
        };
      });
    } catch {
      this.dispatch((state) => {
        return {
          ...state,
          isFetching: false,
          isError: true,
        };
      });
    }
  }

ここまでPub/Subの仕組み作りと非同期関数を登録・実行・実行した時のstateの変異を定義してきました。これらを合わせてObserverとしています(本家?のObserverパターンはよく知らないまま作ってますがとりあえずObserverと命名します)。
追加でもう一つクラスを作ります。TanStack Queryではkeyと非同期関数を登録することで同じURLに対するフェッチでもkeyを変えることで別々のキャッシュを作成することができます。逆に同じkeyで登録すると同一のキャッシュを参照します。これによって複数コンポーネントから同一のURLにリクエストがあった場合キャッシュを利用するため無駄なリクエストが発生しません。「同一URLに対する複数のリクエストを一つにまとめる」という便利機能のために追加のクラスを一つ作ります。

https://github.com/kirikirisu/mini-query/blob/main/packages/core/src/mini-query-client.ts#L1-L53

重要なのは以下の部分だけです。Mapを持つクラスを作り、任意のkeyにより上記のObserverをMapに登録します。ObserverはReactから監視したい非同期関数(fetcher)を登録します。これでkeyとObserverが紐づきました。Observerにはfetcherを渡すのでkey、Observer、fetcherが紐づきました。if (ob instanceof QueryObserver) return ob;によって同じkeyでfetcherが登録された場合は、すでに登録済みのObserverが参照されるようになります。この実装が「同一URLに対する複数のリクエストを一つにまとめる」という機能を再現します(また同じURLでも違うkeyだったら別のキャッシュを参照する機能も再現します)。

export class MiniQueryClient {
  private readonly map = new Map<string, QueryObserver | MutaionObserver>();

  registerQuery<T>(key: string, fetcher: () => Promise<T>) {
    const ob = this.map.get(key);
    if (ob instanceof QueryObserver) return ob;
    if (ob instanceof MutaionObserver)
      throw new Error(
        "This is a duplicate of the key registered with useMutation."
      );

    const observer = new QueryObserver(fetcher);
    this.map.set(key, observer);

    return observer;
  }

少し余談ですがここまでReactの依存は一切なく実装しました。TypeScriptでただのクラスを実装しただけです。今回はpackages/coreとしていますが、Reactの依存がないことを明示するためにjotaiは/vanillaと命名していたりします。

次はここまでに作った仕組みをReact側でどうやって使うのか見ていきます。

https://github.com/kirikirisu/mini-query/blob/main/packages/react/src/provider.tsx#L1-L124

MiniQueryProviderはReactのContextで上記で作ったMiniQueryClientを配布します。client.alodAllをuseStateで実行していますがこれは後述します。useEffectを使いたくなかったのでuseStateの中で関数を実行しています(これでコンポーネントの初期化時に一回だけ実行される)。

export function MiniQueryProvider({
  client,
  children,
}: PropsWithChildren<{ client: MiniQueryClient }>) {
  useState(() => {
    const load = async () => {
      await client.loadAll();
    };

    void load();
  });

  return (
    <StoreContext.Provider value={client}>{children}</StoreContext.Provider>
  );
}

useQueryはTanStack QueryのuseQueryと同じ感じでkeyとPromiseを返す関数を渡します。そしてProviderで配布されているMiniQueryClientのregisterQueryメソッドでObserverを作成&keyとセットで登録しています(MiniQueryClientはkeyとObserverを紐づけるのが主要な機能なのでmapperとしています)。これで指定されたkeyとfetcherでObserverが作成されました。
useSyncExternalStoreの一つ目の引数でkeyに紐ずくObserverにonStoreChange(rerender関数)を登録し、rerender関数によりコンポーネントが再評価された時に参照する値を返す関数を二つ目の引数に渡します。observer.getStateを渡しているのでObserverインスタンスのstateが参照されます。
最後にobserver.load()することでfetcherの実行とfetcherのステータスに合わせてObserverインスタンスのstateの値が変わります。この時にObserverインスタンスのstateが変わると同時にsubscribersに登録されているrerender関数が全て実行されるのでuseSyncExternalStoreでObserverにrerender関数を登録していたコンポーネントは全て再レンダリングされ、二つ目の引数の関数により変異後のstateの値が参照され見た目も変わります。keyを変えると別のObserverを参照するのでコンポーネントによってkeyの指定を変えれば別のデータフェッチをReactと同期させることができますし、key指定を変えなければ複数コンポーネントから同一のObserverを参照させることができ、グローバルステートして機能させることができます。

export function useQuery<K>(key: string, fetcher: () => Promise<K>) {
  const mapper = useContext(StoreContext);
  if (!mapper) {
    throw new Error("wrap Provider");
  }

  const observer = mapper.registerQuery(key, fetcher);

  if (!observer) {
    throw new Error("can not find observer");
  }

  const state = useSyncExternalStore(
    useCallback(
      (onStoreChange) => {
        const unsbscribe = observer.subscribe(onStoreChange);
        return unsbscribe;
      },
      [observer]
    ),
    () => observer.getState<K>()
  );

  useState(() => {
    observer.load();
  });

  return { data: state, refetch: observer.load.bind(observer) };
}

(説明のためにuseSyncExternalStoreを呼んでからuseStateを呼ぶようにしましたが、useStateの方が先に呼ばれているようで、useEffectにするとコード上の順番通り呼ばれたのでuseEffectの方が適切かもしれないです。まぁなんとなく動いてるし試作ということでこのままいきます。「TODO後で調べる」です。)

refetchという関数を返していますが、これは「サーバーから取得したデータを任意のタイミングで再取得できる」を実装しています。TanStack QueryのuseQueryから返されるrefetchを表現しています。実装と言ってもObserverのloadを返しているだけです。コンポーネントでも任意のタイミングでそれを実行するだけです。

https://github.com/kirikirisu/mini-query/blob/main/example/react/src/Todo.tsx#L35-L55

ここまででObserverのStateとReactを同期させる部分を見てきました。ObserverのStateはReactと同期しているのでdispatcherメソッドを通してObserverのStateを更新した場合、subscribersにrerender関数を渡したReactコンポーネントの見た目を変えることができます。ということでReactのStateを更新する関数としてセッターを実装してます。dispatcherを呼び出すだけです。

https://github.com/kirikirisu/mini-query/blob/main/packages/react/src/provider.tsx#L81-L98

コンポーネントではコールバック関数から好きなようにStateを変えるだけです。

https://github.com/kirikirisu/mini-query/blob/main/example/react/src/Todo.tsx#L57-L76

大体完成ですがpreloadとかprefetchみたいなやつの実装を見ていきます。「みたいなやつ」というのは本家の実装を全く見ていなくてなんとなくで実装しています。個人的にアプリケーションの初回レンダリング時にProviderの境界で一度だけ初期値としてサーバーからデータをロードしたいとい思うことがあったのでそれを実装しました。
実装はregisterQueryによりReact外で非同期関数を登録しておき、ReactのProviderで登録した非同期関数を実行しているだけです。

https://github.com/kirikirisu/mini-query/blob/main/example/react/src/App.tsx#L1-L24

const client = new MiniQueryClient();
client.registerQuery(getTodoQueryKey, getTodo);
export function MiniQueryProvider({
  client,
  children,
}: PropsWithChildren<{ client: MiniQueryClient }>) {
  useState(() => {
    const load = async () => {
      await client.loadAll();
    };

    void load();
  });

  return (
    <StoreContext.Provider value={client}>{children}</StoreContext.Provider>
  );
}

最後に実装したかった機能として「同一URLに対する複数のリクエストを一つにまとめる」機能がありますがこちらはやれたらやります。loadでfetcherを実行する際にObserverのStateがあったら実行しない、ただしrefetchの時は実行するみたいな実装になるのかなと思います。

感想

信頼性の高いライブラリを作るにはReactの細かいレンダリングの制御やReactと外部の状態を連携させる時のベストプラクティスみたいなものを理解し実装に落とし込む必要があるんだなと思いました。その点で活発に議論し開発されているライブラリは強いと思いましたが、なぜライブラリを使うのか?を言語化するためにはライブラリの裏側にある事情を理解し、ライブラリが解決している問題を理解していく必要があると思いました。書いてみると当たり前ですが今回自作してみてより腑に落ちるようになりました。

また、実装を VanillaJS と React で切り分けているのは学びになりました。VanillaJS の部分はReact以外のフレームワークで動かす時も挙動が VanillaJS だけで担保されているため、全部Reactに依存して実装した時と比べて共通で使える VanillaJS 分だけ移行コストが低いことが分かりました。VanillaJS を使用しているReactライブラリを採用し、将来的にあるかもしれないフレームワークの移行にも対応しようとする動きも理解できました。一方で VanillaJS 部分はReactの振る舞いには依存しているため、全く別の振る舞いをするフレームワークがデファクトスタンダードになった場合は対処できないと思いました。とはいえ未来予知はできないため現在のデファクトスタンダードとなっているフレームワーク間の移行を少しでも容易にするような設計は重要だと思いました。

まとめ

個人的に TanStack Query を使っていて自動でバックエンドとのデータ同期をしようとするオプションをオフにすることが結構あります。

    retry: 0,
    refetchOnMount: false,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
    staleTime: Infinity,
    gcTime: Infinity,

これは一度取得したデータをブラウザリロードされるまで永続的にReactで持ち、Global Stateとして扱い、完全手動で自分のタイミングでリフェッチ(バックエンドとの同期)を行いたい場合に結構遭遇するからです。今回自作したように機能を極限まで削り特定のケースに対応するシンプルなライブラリであれば自作しても良いかもと思いました。それはそれでメンテどうするの?できるの?とかライブラリを使う時とは別の論点が出てくるので、どちらにしろ都度頑張って調べていく必要がありそうしていきたいと思いました。
今回なんとなく試作してみましたが意外と考えることが出てきて大学の実験レポート以上の真面目度になりました。

Discussion