🏗️

TanStack Query を v5 へ移行したので苦労した点についてまとめる

2024/04/12に公開

はじめに

昨年 10 月に Tanstack Query v5 が正式リリースされました。

破壊的な変更がいくつもあり非常に開発者泣かせのアップデートでしたが、同時に Suspense の正式対応といったアツい機能追加もありました。

https://tanstack.com/blog/announcing-tanstack-query-v5

今回はそんな v5 への移行作業をようやく終えましたので(先延ばしにしすぎた。..)苦労した点や移行して良かった点などについてつらつら書いていこうと思います。

興味ある方はぜひ最後までご覧ください。

TanStack Query について

TanStack Query(旧 React Query)について知らない方もいらっしゃるかと思いますので、簡単に説明しておきます。

TanStack Query は Web 関連の OSS を開発している TanStack から提供されているライブラリの 1 つです。他の有名どころでいうと

  • TanStack Router
  • TanStack Table

などがあります。

https://tanstack.com/

TanStack Query の大きな特徴としては、Web アプリケーションの Server State のフェッチやキャッシュ、同期、更新を簡単にするというものがあります。
(詳細は下記を参考にしてください)

https://tanstack.com/query/v5/docs/framework/react/overview

これにより、開発者はサーバー側から取得してきたデータの管理を非常に簡潔に行うことができるようになります。


ここからは本題の v5 への移行作業について説明していきます。

具体的なアップデート内容

そもそもどんな変更があったのか?代表的なものをあげていきます。

① API のインターフェイスの統一

まずは useQuery やその他多くの API の引数の呼び出し方が統一されました。

具体的には v4 以前は下記のように複数の書き方が可能でしたが、v5 ではオブジェクト形式のみをサポートするようになりました。

use〇〇(...)の統一
- useQuery(key, fn, options)
+ useQuery({ queryKey, queryFn, ...options })

- useInfiniteQuery(key, fn, options)
+ useInfiniteQuery({ queryKey, queryFn, ...options })

- useMutation(fn, options)
+ useMutation({ mutationFn, ...options })

- useIsFetching(key, filters)
+ useIsFetching({ queryKey, ...filters })

- useIsMutating(key, filters)
+ useIsMutating({ mutationKey, ...filters })
queryClient.〇〇(...)の統一
- queryClient.isFetching(key, filters)
+ queryClient.isFetching({ queryKey, ...filters })

- queryClient.ensureQueryData(key, filters)
+ queryClient.ensureQueryData({ queryKey, ...filters })

- queryClient.getQueriesData(key, filters)
+ queryClient.getQueriesData({ queryKey, ...filters })

- queryClient.setQueriesData(key, updater, filters, options)
+ queryClient.setQueriesData({ queryKey, ...filters }, updater, options)

- queryClient.removeQueries(key, filters)
+ queryClient.removeQueries({ queryKey, ...filters })

- queryClient.resetQueries(key, filters, options)
+ queryClient.resetQueries({ queryKey, ...filters }, options)

- queryClient.cancelQueries(key, filters, options)
+ queryClient.cancelQueries({ queryKey, ...filters }, options)

- queryClient.invalidateQueries(key, filters, options)
+ queryClient.invalidateQueries({ queryKey, ...filters }, options)

- queryClient.refetchQueries(key, filters, options)
+ queryClient.refetchQueries({ queryKey, ...filters }, options)

- queryClient.fetchQuery(key, fn, options)
+ queryClient.fetchQuery({ queryKey, queryFn, ...options })

- queryClient.prefetchQuery(key, fn, options)
+ queryClient.prefetchQuery({ queryKey, queryFn, ...options })

- queryClient.fetchInfiniteQuery(key, fn, options)
+ queryClient.fetchInfiniteQuery({ queryKey, queryFn, ...options })

- queryClient.prefetchInfiniteQuery(key, fn, options)
+ queryClient.prefetchInfiniteQuery({ queryKey, queryFn, ...options })

これにより API がより一貫性のあるものになり、オプションを正しく作成するための実行時チェックも必要なくなったためパフォーマンス的にもよくなりました。

また後述しますが、自動修正を支援してくれるcodemodESLint のルールも提供されており、v5 へ移行する際には比較的スムーズに対応できるようになっています。

② useQuery からコールバックを削除

次にuseQueryから onSuccessonSettledonError が削除されました。

以前まで可能だった下記の書き方ができなくなったということですね。

v4以前の書き方
function TodoList() {
  const toast = useToast();

  const { data: todos } = useQuery({
    queryFn: () => getTodos(),
    queryKey: ["getTodos"],
    onError(error) {
      toast({
        title: "Error getting todos",
        description: error.message,
        status: "error",
      });
    },
  });

  return (
    ...
  )
}

これの意図や背景については、TanStack Query のメンテナーの方が詳細に解説していますので割愛します。
個人的にはこの変更はなかなかに衝撃的でした。..

https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose

③ loading ステータスの変更

次に、loading ステータスの定義が変更になりました。
具体的には下記のようになりました。

  • status:loadingstatus:pending
  • isLoadingisPending
  • isPending && isFetching(= isInitialLoading) → isLoading

クエリが無効化されていてデータが存在していない場合にも今まではloadingステータスが true になってしまったため、その代替として新たに Pending というものが追加されたみたいです。

ここに関してはまぁ納得がいく変更だなと個人的には思いました。

https://github.com/TanStack/query/discussions/4252

④ Suspense の正式サポート

最後に今まで experimental であった Suspense が正式に対応し、型レベルでも恩恵を受けられるようになりました。

今回のアップデートの中で 1 番大きな変更と言っても過言ではないでしょう。

具体的には新たにuseSuspenseQueryuseSuspenseInfiniteQueryuseSuspenseQueriesというフックが提供されるようになり、query 系フックでオプションとして提供されていたsuspense: booleanが削除されることになりました。

- const { data: post } = useQuery({
  // const post: Post | undefined
-   queryKey: ['post', postId],
-   queryFn: () => fetchPost(postId),
-   suspense: true
- })
+ const { data: post } = useSuspenseQuery({
  // const post: Post
+   queryKey: ['post', postId],
+   queryFn: () => fetchPost(postId),
+ })

このおかげで、今まで Suspense と TanStack Query を併用した際に undefined にならないはずの値が undefined になってしまうという問題があったのが、ようやく改善されました 🎉

https://github.com/TanStack/query/issues/2306


もちろんここに取り上げたもの以外にも

  • 楽観的更新がシンプルになった
  • cacheTimegcTime
  • useInfiniteQueryinitialPageParamが必須になった

などさまざまな変更がありましたので、実際アップデートを行う際はドキュメントを適宜参照してください。

https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5

移行する際に苦労した点

ここからは移行する際に苦労したことについて簡単にまとめていきます。

基本的にはドキュメントのマイグレーションガイド等を読みながら進めていけるのですが、少し厄介だったのが 「useQuery のコールバック削除(とくに onError)」 です。

こちらに関してはドキュメントや issue を見ても具体的な対応策があまり明示されておらず、query ごとに対応する必要があり、意外と苦労しました。


そもそも TanStack Query のデータ取得 query 系のフックにおけるエラーハンドリングは大きく以下の 3 つがあります。

  1. (QueryClient を作成するときに)グローバルのコールバックを定義する
  2. queryFnからonErrorの副作用を発生させる
  3. ErrorBoundary でキャッチ

たとえば、失敗した際に共通の副作用を実行させたいといったケースでは、1 の 「QueryClient をセットアップする際にqueryCacheオプション内でコールバックを使用する方法」 があげられます。

以下の例では、取得に失敗した場合にトーストが表示されるようになっています。

queryCacheオプション内でコールバックを使用
import {
  QueryCache,
  QueryClient,
} from "@tanstack/react-query";
import { toast } from "react-toastify";

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    // このトーストはQueryごとに一度だけ呼び出される
    onError: (error) => {
      toast(`Something went wrong: ${error.message}`),
    }
  }),
})

またこのグローバルのコールバック内にてもう少しカスタマイズがほしい場合にはuseQuerymetaオプションを利用して分岐させることも可能です。
1 の亜種ですね。

useQueryフックのmetaオプション
export const enum TErrCodes {
  POSTS_FETCH_FAILED,
  // 他のエラーコード
}

const query = useQuery(["posts"], fetchPosts, {
  // fetchが失敗したというエラーコードを伝える
  meta: { errCode: TErrCodes.POSTS_FETCH_FAILED },
});

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: queryCacheOnError,
  }),
});

function queryCacheOnError(err: unknown, query: Query) {
  // meta.errCodeごとにqueryCache内で処理を分岐させる
  switch (query.meta?.errCode) {
    case TErrCodes.POSTS_FETCH_FAILED:
      return toast.error("Could not fetch posts");
    default:
      return toast.error("Something went wrong");
  }
}

さらに、上記と似た方法としては 2 の queryFnからonErrorの副作用を発生させる方法」 もあります。
こちらの場合はほぼonErrorと同一のインターフェイスで運用できるので、後述のErrorBoundaryで運用できない場合は最も優先度的には高いのかなと思いました。

queryFnからonErrorの副作用を発生させる
export const fetchPosts = async (
  ...
  // queryFnにてerrorCallbacksを受け取れるようにしておく
  errorCallbacks?: {
    onUnauthorized?: () => void;
    onConflict?: () => void;
    onDefault?: () => void;
  }
) => {
  try {
    const { data } = await axios.get("/posts");
    return data;
  } catch (error) {
    // errorのstatusごとに実行したい処理を変える
    switch ((error as AxiosResponse).status) {
      case 401:
        errorCallbacks?.onUnauthorized?.();
        break;
      case 409:
        errorCallbacks?.onConflict?.();
        break;
      default:
        errorCallbacks?.onDefault?.();
        break;
    }
    throw error;
  }
};

export const useFetchPosts = (
  ...
  // errorCallbacksをカスタムフックでも受け取れるようにしておく
  errorCallbacks?: {
    onUnauthorized?: (() => void) | undefined;
    onConflict?: (() => void) | undefined;
    onDefault?: (() => void) | undefined;
  }
) =>
  useQuery({
    queryKey: ...,
    queryFn: () => fetchPosts(..., errorCallbacks),
  });

あとはErrorBoundaryを用いてエラーを補足している箇所では次のようにErrorBoundary内でエラーハンドリングを分岐することも可能です。
これが 3 の方法ですね。

ErrorBoundary内にてエラーハンドリングを分岐する
const ErrorBoundaryFallback = ({ error }) => {
  if (axios.isAxiosError(error)) {
    case 403: {
      // Forbiddenのエラーハンドリング
    }
    case 404: {
      // notFoundのエラーハンドリング
    }
    default {
      // その他のAxiosError
    }
  }
  return <>Other Error</>

}

export const AxiosErrorBoundary: FC<{children: ReactNode}> = ({ children }) => {
  return (
    <ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
      {children}
    </ErrorBoundary>
  )
}

プロダクトコード内にて データ取得系 query の記述方法がばらばらな場合(たとえば Suspense や ErrorBoundary を利用しているところとそうでない部分が混在している場合)、このようにチューニングしてくことが求められるため、結構大変でした...🥲

おわりに

ということで、TanStack Query v5 へ移行する際に苦労した点などについて簡単にまとめてみました。

やはりuseSuspenseQueryによって型レベルで Suspense の恩恵を受けられるのはよいですね。
ただ Suspense 境界を適切に分離せずにuseSuspenseQueryをそこら中で呼び出しまくると、リクエストウォーターフォール問題[1]も生じかねませんので使う際はしっかりと設計意図を持って使っていくことをオススメします。

これを見ているみなさんも v5 へ早く移行してぜひ快適な TanStack Query 生活を!

最後までご覧いただきありがとうございました!

参考記事

https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose

https://tkdodo.eu/blog/react-query-error-handling

https://github.com/TanStack/query/discussions/5279

https://github.com/TanStack/query/discussions/4252

https://stackoverflow.com/questions/76961108/react-query-onsuccess-onerror-onsettled-are-deprecated-what-should-i-use-ins

https://speakerdeck.com/taro28/reactnosuspensewoshi-tutafei-tong-qi-chu-li-noerahandoringu

脚注
  1. https://tanstack.com/query/latest/docs/framework/react/guides/request-waterfalls ↩︎

COUNTERWORKS テックブログ

Discussion