M

ミューテーション後のクエリー無効化の自動化

Jan 08, 2025

これは、Dominik Dorfmeister 氏のブログ記事であるAutomatic Query Invalidation after Mutationsを日本語訳してみたものです。

誤訳などあればIssueや PR を頂けると幸いです。


クエリーとミューテーションは同じコインの裏表のようなものです。クエリーは読み取る非同期リソースを定義して、大抵はデータフェッチで得られます。一方ミューテーションはそのようなリソースを更新するアクションです。

ミューテーションが完了すると、クエリーへ影響する可能性が高いです。例えば、issueを更新すると恐らくissuesのリストへ影響します。そのため、React Queryがミューテーションをクエリーと全くリンクさせてないのは多少驚きがあるかもしれません。

その理由はしごく単純です。React Queryがリソースの管理方法へ意見を主張しないためであり、ミューテーション後に再度取得したくない開発者もいるからです。 ミューテーションが更新後のデータを返して、さらなるネットワークラウンドトリップを避けるため、それを手動でキャッシュへ入れたい場合もあります。

また、無効化をどのように行いたいかもさまざまです。

  • onSuccessonSettledで無効化しますか? 前者はミューテーションが成功した場合に限り無効化しますが、後者はエラーの場合にも無効化します。
  • 無効化をawaitしたいですか? 無効化を待つと、再取得が完了するまでミューテーションが保留状態になります。 例えば再取得完了までフォームを無効化したい場合には良いかもしれませんが、詳細画面から概要画面へできるだけ早く遷移したい場合には望まないことかもしれません。

全てに対応する解決策があるわけじゃないので、React Queryはデフォルトで何かを提供することはありません。しかし、グローバルキャッシュコールバックによってReact Queryで自動的に無効化する実装を行うことは難しくありません。

グローバルキャッシュコールバック

ミューテーションにはコールバックがあります。onSuccessonErroronSettledをそれぞれのuseMutationに対して定義する必要があります。同様に、MutationCacheにも同じコールバックが存在します。MutationCacheはアプリケーションでただ1つしかないので、そのコールバックは”グローバル”となります。すべてのミューテーションで呼ばれるということです。

コールバックを持つMutationCacheを作るのは、あまり明らかなことではないでしょう。ほとんどの例ではQueryClientを作成するとき暗黙的にMutationCacheが作られるからです。しかし、キャッシュを手動で作ってコールバックを渡すことも可能です。

import { QueryClient, MutationCache } from '@tanstack/react-query';

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess,
    onError,
    onSettled,
  }),
});

コールバックはuseMutationのそれと同じ引数をとりますが、ミューテーションインスタンスを最後の引数として受け取らない点で異なり、通常のコールバックと同じように返されたPromiseが待機されます。

では、グローバルコールバックの自動的な無効化がどのように助けとなるでしょう?グローバルコールバックでqueryClient.invalidateQueriesを呼び出すだけです。

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: () => {
      queryClient.invalidateQueries();
    },
  }),
});

たった5行のコードでRemix(ごめんなさい、React-Routerですね)のようなフレームワークと似た振る舞いを実現できます。送信時に常にすべてを無効化するというものです。この方法を示してくれたAlexへ感謝します。

しかし、それはやり過ぎではないでしょうか?

そうかもしれないし、そうじゃないかもしれないです。状況によります。繰り返しますが、多くの異なる方法があるのでビルトインではないのです。1つ明確にしておかなければならないことは、無効化は refetch と常に同じではないということです。

無効化は単に一致するすべてのアクティブなクエリーを再取得し、他のものをすべてstaleとしてマークします。次に使用されるときに再取得されるようにするためです。

これは大抵よいトレードオフになります。フィルター機能を持つイシューリストを考えてみましょう。各フィルターがQueryKeyの1部であるべきなので、キャッシュに複数のクエリーが入るでしょう。しかし、同時に表示されるのは1つのクエリーに限ります。それらすべてを再取得すると多くの不要なリクエストが発生する可能性があり、そのフィルターのリストが再び表示されることさえないかもしれません。

無効化は画面上に表示されているもの(アクティブなクエリー)だけ再取得し、ビューを最新の状態に保ちます。そしてそれ以外全て再度必要になったときに再取得されます。

特定のクエリーへ無効化を紐づける

オーケー、ちょっと待ちましょう。きめ細かい再検証はどうでしょうか?issuesリストにイシューを追加するとき、profileデータを無効にする理由があるでしょうか?ほとんど意味がありません...

これもまたトレードオフです。コードは可能な限りシンプルとなり、私は厳密に必要な再取得を忘れるくらいなら頻繁なデータ取得を好みます。きめ細かい再検証は、再取得が必要なものを正確に把握してる場合には良いですが、その一致について拡張しないことがわかっている場合に限ります。

過去には、よくきめ細かい再検証を行ってきましたが、後になって無効化のパターンに合わない他のリソース追加を必要とすることが分かるだけでした。その時点で、すべてのミューテーションのコールバックを見直してそのリソースも再取得が必要かを確認しなければならなかったのです。これは面倒なことであり、エラーが起きやすいです。

その上、私たちはほとんどのクエリーに対して2分程度の中規模のstaleTimeを用います。そのため、無関係のユーザー操作後の無効化による影響は無視できるほど小さいものです。

もちろん、よりスマートな再検証を行うためにロジックをより複雑なものにもできるでしょう。過去に私が使ったテクニックは以下のようなものです。

mutationKeyに紐づける

MutationKeyとQueryKeyには共通するものがなく、Mutationのそれは任意です。MutationKeyを使ってどのクエリーを無効化するか指定したい場合には、これらを紐づけることができます。

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      queryClient.invalidateQueries({
        queryKey: mutation.options.mutationKey,
      });
    },
  }),
});

これでMutationにmutationKey: ['issues']を指定することでイシューにだけ関連する全てを無効化できます。そして、キーのないMutationがあれば、まだ全てが無効化されます。素晴らしいですね。

staleTimeに基づいてクエリーを除外する

私は度々staleTime:Infinityとしてクエリーを”静的”なものにします。このようなクエリーを無効化したくなければ、predicateフィルターを利用して除外できます。

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      const nonStaticQueries = (query: Query) => {
        const defaultStaleTime =
          queryClient.getQueryDefaults(query.queryKey).staleTime ?? 0;
        const staleTimes = query.observers
          .map((observer) => observer.options.staleTime)
          .filter((staleTime) => staleTime !== undefined);

        const staleTime =
          query.getObserversCount() > 0
            ? Math.min(...staleTimes)
            : defaultStaleTime;

        return staleTime !== Number.POSITIVE_INFINITY;
      };

      queryClient.invalidateQueries({
        queryKey: mutation.options.mutationKey,
        predicate: nonStaticQueries,
      });
    },
  }),
});

staleTimeはオブザーバーレベルのプロパティーなので、クエリーの実際のstaleTimeを見つけるのはそう簡単ではないです。しかし、不可能ではなく、predicateフィルターと他のフィルターをqueryKeyのように組み合わせることもできます。素晴らしいですね。

metaオプションを利用する

metaをミューテーションに関する任意の静的な情報を保持するために使用することができます。例えば、ミューテーションに"タグ"としてinvalidatesフィールドを追加できます。このようなタグは無効化したいクエリーを曖昧に一致させることができます。

import { matchQuery } from '@tanstack/react-query';

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      queryClient.invalidateQueries({
        predicate: (query) =>
          // 一致するすべてのタグを1度に無効化
          // もしくは、metaがない場合にはすべてを無効化
          mutation.meta?.invalidates?.some((queryKey) =>
            matchQuery({ queryKey }, query),
          ) ?? true,
      });
    },
  }),
});

// 使い方:
useMutation({
  mutationFn: updateLabel,
  meta: {
    invalidates: [['issues'], ['labels']],
  },
});

ここでは、predicate関数を使ってqueryClient.invalidateQueriesを1度だけ呼びます。しかし、その中でmatchQueryを使って曖昧な一致を行います。matchQuery関数はReact Queryからimportできます。これは単一のqueryKeyをフィルターとして渡すときに内部的に使われる関数ですが、今回は複数のキーを使います。

このパターンは、useMutation自体にonSuccessコールバックを持たせるよりもほんの少しだけ良いかもしれませんが、少なくとも毎回useQueryClientを使ってQueryClientを持ち込む必要がありません。また、デフォルトですべてを無効化することと組み合わせれば、その振る舞いをオプトアプトするよい方法となり得ます。

待つべきか待たないべきか

ここまですべての例で無効化をawaitingするようなことをしていませんが、可能な限り早くミューテーションを終了させたい場合には問題ありません。 特に私がよく遭遇する状況としては、すべてを無効化したいけども、ある重要なデータの再取得が完了するまでミューテーションを保留にしたいというものです。 例えば、あるラベルの更新を待ってからラベル固有のクエリーを再取得したいが、すべてのデータの再取得までは待ちたくないという場合です。

metaオプションを使って、その構造を定義することで解決策を用意することができます。例えば

useMutation({
  mutationFn: updateLabel,
  meta: {
    invalidates: 'all',
    awaits: ['labels'],
  },
});

もしくは、useMutationのコールバックのにMutationCacheのコールバックが実行される事実を利用することもできます。 グローバルコールバックですべてを無効化する設定にしている場合においても、awaitするようにローカルコールバックを追加することができます。

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: () => {
      queryClient.invalidateQueries();
    },
  }),
});

useMutation({
  mutationFn: updateLabel,
  onSuccess: () => {
    // 待つようにPromiseを返す
    return queryClient.invalidateQueries(
      { queryKey: ['labels'] },
      { cancelRefetch: false },
    );
  },
});

何が行われているかというと、

  • 最初に、グローバルコールバックが実行されてすべてのクエリーが無効化されますが、awaitも何かreturnもしてないので、"ファイアー・アンド・フォーゲット"の無効化となります。
  • その後すぐローカルコールバックが実行されて、['labels']だけを無効化するPromiseを作ります。このPromiseを返すことで、['labels']が再取得されるまでミューテーションが保留状態になります。

これは、自動的な無効化のため納得いく抽象化を追加することに必要なコードが多くないことを示していると思います。 すべての抽象化にはコストがかかることを覚えておいてください。それは新しいAPIであり、学び、理解し、適切に適用する必要があるのです。

すべての可能性を示すことで、React Queryに何もビルトインされてない理由が少しでも明示できいたらと思います。 膨れ上がることなくすべてのケースを網羅する柔軟なAPIを見出すことは簡単なことではありません。そのため、ユーザー側でそれを作れるようにするツールを提供することが好ましいと考えています。