TanStack Query を v5 へ移行したので苦労した点についてまとめる
はじめに
昨年 10 月に Tanstack Query v5 が正式リリースされました。
破壊的な変更がいくつもあり非常に開発者泣かせのアップデートでしたが、同時に Suspense の正式対応といったアツい機能追加もありました。
今回はそんな v5 への移行作業をようやく終えましたので(先延ばしにしすぎた。..)苦労した点や移行して良かった点などについてつらつら書いていこうと思います。
興味ある方はぜひ最後までご覧ください。
TanStack Query について
TanStack Query(旧 React Query)について知らない方もいらっしゃるかと思いますので、簡単に説明しておきます。
TanStack Query は Web 関連の OSS を開発している TanStack から提供されているライブラリの 1 つです。他の有名どころでいうと
- TanStack Router
- TanStack Table
などがあります。
TanStack Query の大きな特徴としては、Web アプリケーションの Server State のフェッチやキャッシュ、同期、更新を簡単にするというものがあります。
(詳細は下記を参考にしてください)
これにより、開発者はサーバー側から取得してきたデータの管理を非常に簡潔に行うことができるようになります。
ここからは本題の v5 への移行作業について説明していきます。
具体的なアップデート内容
そもそもどんな変更があったのか?代表的なものをあげていきます。
① API のインターフェイスの統一
まずは useQuery
やその他多くの API の引数の呼び出し方が統一されました。
具体的には v4 以前は下記のように複数の書き方が可能でしたが、v5 ではオブジェクト形式のみをサポートするようになりました。
- 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.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 がより一貫性のあるものになり、オプションを正しく作成するための実行時チェックも必要なくなったためパフォーマンス的にもよくなりました。
また後述しますが、自動修正を支援してくれるcodemodや ESLint のルールも提供されており、v5 へ移行する際には比較的スムーズに対応できるようになっています。
② useQuery からコールバックを削除
次にuseQuery
から onSuccess
、onSettled
、onError
が削除されました。
以前まで可能だった下記の書き方ができなくなったということですね。
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 のメンテナーの方が詳細に解説していますので割愛します。
個人的にはこの変更はなかなかに衝撃的でした。..
③ loading ステータスの変更
次に、loading ステータスの定義が変更になりました。
具体的には下記のようになりました。
-
status:loading
→status:pending
-
isLoading
→isPending
-
isPending && isFetching
(=isInitialLoading
) →isLoading
クエリが無効化されていてデータが存在していない場合にも今まではloading
ステータスが true になってしまったため、その代替として新たに Pending というものが追加されたみたいです。
ここに関してはまぁ納得がいく変更だなと個人的には思いました。
④ Suspense の正式サポート
最後に今まで experimental であった Suspense が正式に対応し、型レベルでも恩恵を受けられるようになりました。
今回のアップデートの中で 1 番大きな変更と言っても過言ではないでしょう。
具体的には新たにuseSuspenseQuery
とuseSuspenseInfiniteQuery
、useSuspenseQueries
というフックが提供されるようになり、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 になってしまうという問題があったのが、ようやく改善されました 🎉
もちろんここに取り上げたもの以外にも
- 楽観的更新がシンプルになった
-
cacheTime
→gcTime
-
useInfiniteQuery
にinitialPageParam
が必須になった
などさまざまな変更がありましたので、実際アップデートを行う際はドキュメントを適宜参照してください。
移行する際に苦労した点
ここからは移行する際に苦労したことについて簡単にまとめていきます。
基本的にはドキュメントのマイグレーションガイド等を読みながら進めていけるのですが、少し厄介だったのが 「useQuery のコールバック削除(とくに onError
)」 です。
こちらに関してはドキュメントや issue を見ても具体的な対応策があまり明示されておらず、query ごとに対応する必要があり、意外と苦労しました。
そもそも TanStack Query のデータ取得 query 系のフックにおけるエラーハンドリングは大きく以下の 3 つがあります。
- (QueryClient を作成するときに)グローバルのコールバックを定義する
-
queryFn
からonError
の副作用を発生させる - ErrorBoundary でキャッチ
たとえば、失敗した際に共通の副作用を実行させたいといったケースでは、1 の 「QueryClient をセットアップする際に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}`),
}
}),
})
またこのグローバルのコールバック内にてもう少しカスタマイズがほしい場合にはuseQuery
のmeta
オプションを利用して分岐させることも可能です。
1 の亜種ですね。
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
で運用できない場合は最も優先度的には高いのかなと思いました。
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 の方法ですね。
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 生活を!
最後までご覧いただきありがとうございました!
参考記事
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion