🌺

Tanstack Queryをv4 → v5へ移行したので手順と勘所をまとめる

2024/05/27に公開

はじめに

参画しているサービス開発のプロジェクトにて、Tanstack Queryのv4 → v5の移行作業を行いました。

やや破壊的な変更が多く移行作業は地味に大変でしたが、Suspenseの正式対応で型レベルでも恩恵を受けられるようになったり、楽観的更新(Optimistic Updates)を手軽に実装できるようになったりと、メリットも多く移行して良かったなと思います🎉

今回は実際に行った移行作業や苦労した点、移行して良かった点などについて書いていこうと思います。

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

Tanstack Query v4 → v5の移行作業

実際に行った作業を変更内容と合わせて紹介します。

use〇〇、queryClient.〇〇の引数の書き換え

TypeScript 4.7 のアップデートによってインターフェイスの統一が可能になり、
useQueryuseMutationなどその他多くの API の引数のオブジェクト形式に統一されました。

例:useQuery
- useQuery(key, fn, options) 
+ useQuery({ queryKey, queryFn, ...options })
例:queryClient.invalidateQueries
- 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に書き換え

グローバルのオプションでは、cacheTimegcTimeにリネームされました。
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,
    },
  },
})

useQueryのコールバック(onErrorなど)を削除

useQueryから onErroronSuccessonSettled が削除されました。
https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose

  • onError のコールバックは useQuery ごとに呼ばれるため、同じキーの useQuery を複数コンポーネントで使用するとそれぞれで onError が呼ばれる
  • staleTime が定義されている・さらにその時間内の場合、データフェッチをしてもキャッシュが返却されるため onSuccess が呼ばれない

といった経緯が記載されています。

以前まで可能だったonErrorで副作用の実行する下記のような書き方はできなくなりました。

v4以前
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パターンがあるかなと思います。

  1. onSuccess / onError→query系フックをラップしてカスタムコールバックをuseEffectで実行
  2. onErrorqueryFnのcatchで実行
  3. onErrorErrorBoundary でキャッチ
  4. 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.onErrorqueryFnの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. onErrorErrorBoundary でキャッチ
SuspenseErrorBoundary を利用しているところであれば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 が正式に対応し、型レベルでも恩恵を受けられるようになりました🎉

useSuspenseQueryuseSuspenseInfiniteQueryuseSuspenseQueriesが利用できるようになり、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が一つ(ないし少ない)の場合には積極的に採用しても良さげですね。

TODOを追加するmutationの例
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>
);

具体的なこちらの記事で詳しく解説されいるのでご興味ありましたらご覧ください🙏

https://zenn.dev/frontendflat/articles/tanstack-optimistic-update

https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5#simplified-optimistic-updates

isLoading → isPendingに書き換え

今まではenabled: falseなどでクエリが無効の場合に、loadingステータスが true になってしまったため、isLoading && isFetchingなどと組み合わせる必要がありました。
そのためisLoadingの代替として新たに isPending が追加されたみたいです。

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

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

https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5#status-loading-has-been-changed-to-status-pending-and-isloading-has-been-changed-to-ispending-and-isinitialloading-has-now-been-renamed-to-isloading

keepPreviousData -> placeholderData: keepPreviousData

  • keepPreviousDataisPreviousData
  • placeholderDataisPlaceholderData

はほとんど同じことをしていたということで、keepPreviousDataは削除されました。

import {
   useQuery,
+  keepPreviousData
} from "@tanstack/react-query";

const {
   data,
-  isPreviousData,
+  isPlaceholderData,
} = useQuery({
  queryKey,
  queryFn,
- keepPreviousData: true,
+ placeholderData: keepPreviousData
});

https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5#removed-keeppreviousdata-in-favor-of-placeholderdata-identity-function

useInfiniteQueryにinitialPageParamを追加

useInfiniteQueryinitialPageParamが必須になったため書き換えをおこないました(useSuspenseInfiniteQueryも同様)

useInfiniteQuery({
   queryKey: ["todos"],
-  queryFn: ({ pageParam = 0 }) => getTodos(pageParam),
+  queryFn: ({ pageParam }) => getTodos(pageParam),
+  initialPageParam: 0,
   getNextPageParam: (lastPage) => lastPage.next,
})

https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5#infinite-queries-now-need-a-initialpageparam

移行してよかった点

Suspenseが正式に対応されたことで、型レベルでも恩恵を受けられるようになったことでコードがだいぶ綺麗になりました。
useSuspenseQueryuseSuspenseInfiniteQueryuseSuspenseQueriesが利用できるようになり、戻り値のdataのフェイルセーフが不要になったのが特によかったです🎉

移行して苦労した点

useQueryのコールバック廃止 & useQueryuseSuspenseQueryへの書き換えに伴う、onSuccess / onErrorの削除 + 代替実装は地味に大変でした💦

移行当時だと公式ドキュメントやissueでベストプラクティスが明示されていなかったため、既存のqueryの実装に合わせて、適宜対応していたため、意外と苦労しました。

さいごに

今回はTanStack Query v5 へ移行するの手順やよかった点、苦労した点などについて簡単にまとめてみました。
これからアプデを検討している方の参考になれば幸いです!

frontend flat

Discussion