Tanstack Queryをv4 → v5へ移行したので手順と勘所をまとめる
はじめに
参画しているサービス開発のプロジェクトにて、Tanstack Queryのv4 → v5の移行作業を行いました。
やや破壊的な変更が多く移行作業は地味に大変でしたが、Suspense
の正式対応で型レベルでも恩恵を受けられるようになったり、楽観的更新(Optimistic Updates)を手軽に実装できるようになったりと、メリットも多く移行して良かったなと思います🎉
今回は実際に行った移行作業や苦労した点、移行して良かった点などについて書いていこうと思います。
Tanstack Query v4 → v5の移行作業
実際に行った作業を変更内容と合わせて紹介します。
use〇〇、queryClient.〇〇の引数の書き換え
TypeScript 4.7 のアップデートによってインターフェイスの統一が可能になり、
useQuery
やuseMutation
などその他多くの API の引数のオブジェクト形式に統一されました。
- useQuery(key, fn, options)
+ useQuery({ queryKey, queryFn, ...options })
- queryClient.invalidateQueries(key, filters, options)
+ queryClient.invalidateQueries({ queryKey, ...filters }, options)
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 })
cacheTime→gcTimeに書き換え
グローバルのオプションでは、cacheTime
がgcTime
にリネームされました。
gcTime
(旧cacheTime
)は使われなくなったキャッシュのデータをGC(ガベージコレクション)するまでの時間です。
gcTimeはnumber型もしくはInfinityを設定します。デフォルトは5 * 60 * 1000(5分)になっており、Infinityを設定した場合はGCを無効化することができます。
「ガベージ・コレクト(gc)するまでの時間」なのでgcTimeの方がしっくりきますね
const MINUTE = 1000 * 60;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- cacheTime: 10 * MINUTE,
+ gcTime: 10 * MINUTE,
},
},
})
onError
など)を削除
useQueryのコールバック(useQuery
から onError
、onSuccess
、onSettled
が削除されました。
-
onError
のコールバックはuseQuery
ごとに呼ばれるため、同じキーのuseQuery
を複数コンポーネントで使用するとそれぞれでonError
が呼ばれる -
staleTime
が定義されている・さらにその時間内の場合、データフェッチをしてもキャッシュが返却されるためonSuccess
が呼ばれない
といった経緯が記載されています。
以前まで可能だったonError
で副作用の実行する下記のような書き方はできなくなりました。
const getTodos = () => {
return axios.get<GetTodosResponse, GetTodosResponse>(`/todos`);
};
const useGetTodos = () => {
return useQuery({
queryFn: getTodos,
queryKey: ["todos"],
suspense: true,
onError(error) { // 廃止
toast.error(`Failed to get todos. ${error.message}`);
},
});
};
廃止されたuseQueryのコールバックの代替実装
v5以降、query系フックのonSuccess
/ onError
の代わりの実装として以下の4パターンがあるかなと思います。
-
onSuccess
/onError
→query系フックをラップしてカスタムコールバックをuseEffectで実行 -
onError
→queryFn
のcatchで実行 -
onError
→ErrorBoundary
でキャッチ -
QueryClient
でグローバルのコールバックを定義
1. onSuccess
/ onError
→query系フックをラップしてカスタムコールバックをuseEffectで実行
query系フックをラップしてonError
などのコールバックをを追加した hooks を定義することでv4以前と同じような書き心地にすることがきます
+ import { UseQueryOptions, UseQueryResult, useQuery as originUseQuery } from "@tanstack/react-query";
+ import { useEffect } from "react";
+ export type QueryCallbacks<TData, TError = unknown> = {
+ onSuccess?: (data: TData) => void;
+ onError?: (error: TError) => void;
+ onSettled?: (data?: TData, error?: TError) => void;
+ };
+ export const useQuery = <TQueryFnData = unknown, TError = unknown, TData = TQueryFnData>(
+ params: UseQueryOptions<TQueryFnData, TError, TData> & QueryCallbacks<TData, TError>
+ ): UseQueryResult<TData, TError> => {
+ const { onSuccess, onError, onSettled, ...queryParameters } = params;
+ const result = originUseQuery<TQueryFnData, TError, TData>(queryParameters);
+
+ useEffect(() => {
+ if (result.isSuccess && onSuccess) {
+ onSuccess(result.data);
+ }
+ if (result.isError && onError) {
+ onError(result.error);
+ }
+ onSettled?.(result.data, result.error || undefined);
+ }, [result.isSuccess, result.isError, result.data, result.error, onSuccess, onError, onSettled]);
+
+ return result;
+ };
export const getTodos = () => {
return axios.get<GetTodosResponse, GetTodosResponse>(`/todos`);
};
// 使用感はv4以前と同じ
export const useGetTodos = () => {
return useQuery({
queryKey: ["todos"],
queryFn: getTodos,
onSuccess: () => {
// Success時の処理
},
onError: () => {
// Error時の処理
},
});
};
2.onError
→queryFn
のcatchで実行
- export const getTodos = () => {
- return axios.get<GetTodosResponse, GetTodosResponse>(`/todos`);
- };
- export const useGetTodos = () => {
- return useQuery({
- queryKey: ["todos"],
- queryFn: getTodos,
- onError: () => {
- // Error時の処理
- },
- });
- };
+ export const getTodos = () => {
+ return axios
+ .get<GetTodosResponse, GetTodosResponse>(`/todos`)
+ .catch(() => {
+ // Error時の処理
+ });
+ };
+ export const useGetTodos = () => {
+ return useQuery({
+ queryKey: ["todos"],
+ queryFn: getTodos,
+ });
+ };
3. onError
→ErrorBoundary
でキャッチ
Suspense
や ErrorBoundary
を利用しているところであればErrorBoundary
内でエラーハンドリングを行うことができます
const ErrorBoundaryFallback = ({ error }) => {
if (Axios.isAxiosError(error)) {
switch (error.response?.status) {
case 400: {
// BAD_REQUEST handling
break;
}
case 404: {
// NOT_FOUND handling
break;
}
default: {
// その他のAxiosError
}
}
}
return <DefaultErrorComponent errorMessage={error.message} />;
};
export const AxiosErrorBoundary = ({ children }: PropsWithChildren) => {
return <ErrorBoundary fallbackRender={ErrorBoundaryFallback}>{children}</ErrorBoundary>;
};
4. QueryClientでグローバルのコールバックを定義する
グローバルのコールバック内でmeta
オプションを利用することもできます。
import { Query, useQuery, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (_, query: Query) => {
toast(`${query.meta?.errorMessage}`);
},
}),
});
export const useGetTodos = () => {
return useQuery({
queryKey: ["todos"],
queryFn: getTodos,
meta: { errorMessage: "Failed to get todos." },
});
};
今回のプロジェクトでは、
- エラー時のトースト表示などの副作用は
1.onSuccess / onError→query系フックをラップしてカスタムコールバックをuseEffectで実行
で対応 - エラーstatusに応じた表示の出しわけなどは
3. ErrorBoundary でキャッチ
で対応
という感じで書き換えを行いました。
Suspense対応(useQuery→useSuspenseQueryに書き換え)
v5からSuspense が正式に対応し、型レベルでも恩恵を受けられるようになりました🎉
useSuspenseQuery
とuseSuspenseInfiniteQuery
、useSuspenseQueries
が利用できるようになり、useQuery
などのオプションとして提供されていたsuspense: boolean
が削除され、戻り値のdataのフェイルセーフも不要とになりました。
const getTodos = () => {
return axios.get<GetTodosResponse, GetTodosResponse>(`/todos`);
};
- const useGetTodos = () => {
- return useQuery({
- queryFn: getTodos,
- queryKey: ["todos"],
- suspense: true,
- });
- };
+ const useGetTodos = () => {
+ return useSuspenseQuery({
+ queryFn: getTodos,
+ queryKey: ["todos"],
+ });
+ };
query系のフックを呼び出す側のコンポーネントもSuspense導入 + フェイルセーフが不要になるためかなり可読性が向上しました。
- export const Todos = () => {
- const { data: todos, isLoading } = useGetTodos();
- return isLoading ? (
- <>loading...</>
- ) : (
- todos && todos.length > 0 && (
- <div>
- {todos.map(() => (
- ...
- ))}
- </div>
- )
- );
- };
+ export const Todos = () => {
+ const { data: todos } = useGetTodos();
+ return <div>{todos.map(() => ...)}</div>;
+ };
ただ同一コンポーネント内でuseSuspenseQuery
を呼び出しまくると、リクエストウォーターフォール問題も生じかねませんので、
-
useSuspenseQueries
を使用する - コンポーネントを分割する
などをしつつ、どの範囲を Suspense
で囲むかという設計意図を持って使っていくことをオススメします。
variablesを使った楽観的更新のリファクタ
v5から楽観的更新(Optimistic Updates)がシンプルになりました、より手軽に実装できるようになったので楽観的更新を実装するUIが一つ(ないし少ない)の場合には積極的に採用しても良さげですね。
const { isPending, variables, mutate, isError } = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
});
return (
<ul>
{todoQuery.items.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
);
具体的なこちらの記事で詳しく解説されいるのでご興味ありましたらご覧ください🙏
isLoading → isPendingに書き換え
今まではenabled: false
などでクエリが無効の場合に、loadingステータスが true になってしまったため、isLoading && isFetching
などと組み合わせる必要がありました。
そのためisLoading
の代替として新たに isPending
が追加されたみたいです。
-
status:loading
→status:pending
-
isLoading
→isPending
-
isPending && isFetching(= isInitialLoading)
→isLoading
keepPreviousData -> placeholderData: keepPreviousData
-
keepPreviousData
とisPreviousData
-
placeholderData
とisPlaceholderData
はほとんど同じことをしていたということで、keepPreviousData
は削除されました。
import {
useQuery,
+ keepPreviousData
} from "@tanstack/react-query";
const {
data,
- isPreviousData,
+ isPlaceholderData,
} = useQuery({
queryKey,
queryFn,
- keepPreviousData: true,
+ placeholderData: keepPreviousData
});
useInfiniteQueryにinitialPageParamを追加
useInfiniteQuery
にinitialPageParam
が必須になったため書き換えをおこないました(useSuspenseInfiniteQuery
も同様)
useInfiniteQuery({
queryKey: ["todos"],
- queryFn: ({ pageParam = 0 }) => getTodos(pageParam),
+ queryFn: ({ pageParam }) => getTodos(pageParam),
+ initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.next,
})
移行してよかった点
Suspenseが正式に対応されたことで、型レベルでも恩恵を受けられるようになったことでコードがだいぶ綺麗になりました。
useSuspenseQuery
とuseSuspenseInfiniteQuery
、useSuspenseQueries
が利用できるようになり、戻り値のdataのフェイルセーフが不要になったのが特によかったです🎉
移行して苦労した点
useQuery
のコールバック廃止 & useQuery
→useSuspenseQuery
への書き換えに伴う、onSuccess
/ onError
の削除 + 代替実装は地味に大変でした💦
移行当時だと公式ドキュメントやissueでベストプラクティスが明示されていなかったため、既存のqueryの実装に合わせて、適宜対応していたため、意外と苦労しました。
さいごに
今回はTanStack Query v5 へ移行するの手順やよかった点、苦労した点などについて簡単にまとめてみました。
これからアプデを検討している方の参考になれば幸いです!
Discussion