😂

React Query で無邪気に refetch を使って失敗した話

2022/07/19に公開

React Query とは

外部データの取得、キャッシュ、同期、更新がフックベースの API で簡単に実現できるライブラリです。
この記事では詳しく説明しないので、そもそも知らない方や導入検討している方は 公式ドキュメントの概要や、REST API なら React Query がファーストチョイスなどを見てみると良いかなと思います。(後者の記事に関しては、筆者もラッパー部分の設計を参考にさせていただきました🙇🏻)

refetch を使って API を叩いてデータを変更した後に、再取得する処理を書きたい

React Query を使い始めの頃、上記のように更新後の再取得というよくある処理を書きたくなり、useQuery 返り値に refetch という関数を見つけたので、以下のような実装を行いました。

export const useTodos = (params?: TodoRequestParams) => {
  const { data, refetch } = useQuery(['todos', params], fetchTodoList);

  const onUpdate = useCallback(async, () => {
    await updateTodo();
    refetch(); // 更新が完了したら再取得の処理を行う
  });

  return {
    data,
    onUpdate
  };
};

View 層からこのカスタムフックを呼び出して、TODO のデータを呼び出したり更新 + 再取得の処理を行うような実装です。useQuery のドキュメントと型定義を見つつ、意図通りの実装だろ〜と思ってたのですが、動作確認してみると自分がやりたいユースケースと合っていないことがわかりました。

refetch は紐づいているクエリに対してのみ再取得の処理を行う

考えてみれば当たり前なのですが、refetch は useQuery の返り値であり、そのクエリに対して再取得を行います。

単純な構造であればこの仕様は問題にならないのですが、想定していたユースケースは、以下の実装例のように単一の画面で別々のクエリパラメータを渡してデータを取得しており、それらを全て更新したいというものでした。

これを現状の実装で実現しようとすると全ての refetch を呼び出す必要があります。
また、refetch の呼び出し漏れが発生するリスクも存在します。実際自分たちはこの問題に遭遇しました。
(若干例が雑なのはお許しください)

const TodoList = () => {
  const { data: all, refetch: refetchAll, onUpdate } = useTodo();
  const { data: done, refetch: refetchDone } = useTodo({ type: 'done' });
  const { data: undone, refetch: refetchUndone } = useTodo({ type: 'undone' });

  // 異なるパラメータの分だけ網羅する必要がある。
  // 同一のファイルでは起きづらいかもしれないが、
  // 複数コンポーネントのファイルにまたがる場合に呼び出し漏れは容易に発生しそう。
  const handleUpdate = async () => {
    await onUpdate();
    refetchAll();
    refetchDone();
    // undone の更新がもれている!!!
  };
};

queryClient.refetchQueries を使えば良いのでは?

上記の問題は要するに todos というキーが含まれるクエリ全体に対して再取得する処理を書いておけば良いので、指定した条件に当てはまるクエリを全て再実行する queryClient.refecthQueies を使えば解決できそうに見えます。

何より refetch という名前が付いているので再取得したい意図とあっていそうです(名前からの連想って大事)。
ただ、refetchQueries を使う際も少し気をつけて起きたいポイントがあります。

refetchQueies はデフォルトの挙動だと inActive なクエリの再取得も行ってしまう

React Query のドキュメントや DevTool などを見ると分かるのですがキャッシュしているデータには 3 つの状態があります。

  • fresh: データが最新の状態である
  • stale: データが古い状態である
  • inActive: データがどのコンポーネントからも参照されていない

このうち、現在参照されているかつ古い状態のデータを再取得するのが一番無駄なく効率的なので stale 状態のデータを対象としたいのですが、refetchQueries はデフォルトの挙動でデータの状態に限らずクエリの再実行を行うので、fresh や inActive なデータも再取得されてしまいます。

ただ、これに関しては refetchQueries の引数に QueryFilter を指定することができるので、以下のようにして再取得の対象を絞ることができます(公式のサンプルコードを拝借)。

 // stale 状態の全てのクエリに対して再取得を行う
 await queryClient.refetchQueries({ stale: true });

 // ‘posts’ をキーに含んでおり、active なクエリに対してのみ再取得を行う
 await queryClient.refetchQueries([‘posts’], { active: true });

refetchQueries で解決??

懸念とか言ってたけど、やりたいことは実現できた。これで解決? React Query もこれを想定しているんでしょ?
と思いきや、ドキュメントや TkDodo のブログを見ていると refetch や refetchQueriese に関して触れられている箇所はほとんどありません🤔

代わりに副作用後の再取得の文脈で触れられているのは QueryInvalidation という仕組みです。

QueryInvalidation

React Query には invalidateQueries という QueryInvalidation を行うための関数が用意されており、指定した QueryKey と QueryFilter に合致したクエリに対して以下の処理を行います。

  • stale 状態にする。その際、staleTime の設定などは無視される
  • 現在参照されているクエリに対しては、バックグラウンドで再取得の処理が走る

クエリの無効化 = そのクエリに紐づくデータを古いものとして扱うというような意味合いです。
呼び出しのイメージはこちら。

// todos を QueryKey に含むクエリを invalidate
queryClient.invalidateQueries(‘todos’)

// QueryKey が ‘todos’ と完全一致するクエリを invalidate
queryClient.invalidateQueries(‘todos’, { exact: true })

副作用後のデータ更新の思想について

ドキュメントやメンテナーのブログを見る限り、useMutation などで副作用が発生した後は、この QueryInvalidation の処理を行うべきとしています。

というのもそもそも React Query には stale 状態になったデータは、defaultOptions や useQuery の options で設定したタイミングに従い、自動的にバックグラウンドで再取得されます。

であれば例え副作用が起きた場合でも、stale 状態に変更するだけにして、自動再取得の流れに乗せた方がシンプルだよねということで、QueryInvalidation を使う方針なのかなと思います。

これにより細かい意識をせずに不要な refetch が走ることによるパフォーマンス懸念をなくすことができます。

余談ですが、React Query には useMutation 内でレスポンスを使用して直接キャッシュを変更する API (setQueryData など)も用意されていますが、フロントもバックエンドも複雑になる可能性があがるので、基本的に QueryInvalidation を使うべきとされています。

これを踏まえてサンプルのコードを書き換えてみる

export const useTodos = (params?: TodoRequestParams) => {
  const { data } = useQuery([‘todos’, params], fetchTodoList);
  const queryClient = useQueryClient();

  // todos を持つ全てを再取得する仕様にしましたが、ここは要件に応じて変えていく
  const onUpdate = useCallback(async, () => {
    await updateTodo();
    queryClient.invalidateQueries(‘todos’);
  });

  return {
    data,
    onUpdate
  }
}

const TodoList = () => {
  const { data: all, onUpdate } = useTodo();
  const { data: done } = useTodo({ type: 'done' });
  const { data: undone } = useTodo({ type: 'undone' });

  const handleUpdate = async () => {
    await onUpdate();
  };
};

まとめ

  • refetch や refetchQueries は使う上で少し注意が必要。もし使いたい場合はユースケースに沿っているかしっかり確認した方が良い
  • 副作用発生後のキャッシュの更新には QueryInvalidation (queryClient.invalidateQueries) を使うことが推奨されている

最後に

React Query はもちろん、フロントの設計周りについて意見の交換できればと思って Meety を立てたので、興味がある方ざっくばらんにお話しましょう〜!!

Discussion