🐷

【Nextjs】今更ながらTanStack Queryを使ってみた

に公開

はじめに

最近、React/Nextjsの開発でTanStack Queryを使ってみたので、その備忘録を残しておきます。
TanStack Queryは、ReactやNext.jsなどのフロントエンドでデータを取得、キャッシュ、更新するためのライブラリです。
使ってみるとコード量もだいぶ削減できて、コード品質的にもいい感じになるのでおすすめです。

https://tanstack.com/query/latest

今回の解説では、個人開発している日報管理ツールの日報の年月日を表示するサイドばーを対象に修正前と修正後でコードを比較してみました。

開発環境

  • Nextjs 15.1.6
  • TanStack Query 5.83.0
  • pnpm 10.1.0

インストール

以下のコマンドでインストールします。

pnpm add @tanstack/react-query

TanStack Queryを使ってAPIを呼び出す

修正前後でGETメソッドのAPIを呼び出すコードを比較してみます。

修正前

TanStack Queryを使う前のコードについて説明して行きます。

Providerです。
修正前は、特になにもしていないので、カスタムフックのuseFetchReportDatesを呼び出しているだけです。
Rootのlayout.tsxでProviderを呼び出しており、初期化するために使っています。

src/app/provider.tsx
"use client"

import { useFetchReportDates } from "@/src/features/report/model/useFetchReportDates"

export const Provider = ({ children }: { children: React.ReactNode }) => {
	useFetchReportDates()

	return <>{children}</>
}

カスタムフックのuseFetchReportDatesです。
getDatesというAPIを呼び出す処理が書かれています。
useEfffectで、currentDate.yearが変更されたら、APIを呼び出しています。
ここでは、書いていませんが、APIを実行前後でローディングを表示する場合は、useStateなどを使って状態を定義して使うこともあるかと思います。
特にこの観点が、TanStack Queryを使う場合でどうなるかを後ほどで説明する修正後の内容で見てください。

src/features/report/hooks/useFetchReportDates.ts
"use client"

import { currentDateAtom, yearDatesAtom } from "@/src/entities/report/model"
import { getDates } from "@/src/features/report/api/getDates"
import { useAtom } from "jotai"
import { useEffect, useRef } from "react"

export const useFetchReportDates = () => {
	const [, setYearDatesAtom] = useAtom(yearDatesAtom)
	const [currentDate] = useAtom(currentDateAtom)
	const prevYearRef = useRef<number | null>(null)

	useEffect(() => {
		const fetchReport = async () => {
			if (
				currentDate.year &&
				(!prevYearRef.current || currentDate.year !== prevYearRef.current)
			) {
				prevYearRef.current = currentDate.year
				const result = await getDates(currentDate.year)

				if (result) {
					setYearDatesAtom(result)
				}
			}
		}
		fetchReport()
	}, [currentDate.year, setYearDatesAtom])
}

この例では、useFetchReportDatesで日付データを取得して、サイドバーに日付を表示するようなコードになっています。
修正前では、特に何もしていないのですが、修正後でサイドバーのコンポーネント側もTanStack Queryを使うケースでは変更を加えるので、
ここに書いておきます。

src/widgets/sidebar/ui/Sidebar.tsx
"use client"

import { SelectDate, SelectYear } from "@/src/features/select-date"
import type React from "react"

export const Sidebar: React.FC = () => {
	return (
		<>
			<div className="w-64 h-screen bg-gray-100 overflow-y-auto flex flex-col">
				<SelectYear />
				<SelectDate />
			</div>
		</>
	)
}

修正後

修正後のコードについて説明して行きます。

Provider側としては、QueryClientProviderを使って、QueryClientを初期化しています。
これで、TanStack Queryを内部のコンポーネントで使うことができます。

QueryClientの設定として、デフォルト値を以下のように設定しています。

  • staleTime: データを60秒間新鮮扱い(再フェッチしない)
  • gcTime: キャッシュを保持時間(5分)
  • retry: 失敗時リトライ回数(3回)
  • retryDelay: リトライ間隔(回数x2で最大30秒)
  • refetchOnWindowFocus: ブラウザのタブやウィンドウに戻ったときに再フェッチ許可(しない)

ちなみに、staleTimeとgcTimeについて理解が難しかったのでさらに説明すると以下の通りです。

  • staleTime:API取得直後に同じクエリが来ても、毎回API叩かずキャッシュを使う(例:1分以内に同じ画面を何度開いてもAPIリクエストは1回だけ)。
  • gcTime:staleTimeを過ぎてもキャッシュを残すことで、再フェッチ時に一瞬でも古いデータを表示できる(例:5分以内ならローディング無しで古いデータ→新データに即座に切り替え)。staleTimeが過ぎたときに再フェッチでキャッシュデータは更新されるため、一見意味なさそうに見えますが、再フェッチ失敗などでキャッシュデータが更新されない場合は、古いデータを表示することができます。

両方あることで「API負荷軽減」と「UX向上」を両立できるので、意味があることがわかります。

src/app/provider.tsx
"use client"

import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useState } from "react"

const queryClient = new QueryClient({
	defaultOptions: {
		queries: {
			staleTime: 60 * 1000,
			gcTime: 5 * 60 * 1000,
			retry: 3,
			retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
			refetchOnWindowFocus: false,
		},
	},
})

export const Provider = ({ children }: { children: React.ReactNode }) => {
	const [client] = useState(() => queryClient)

	return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}

カスタムフックの中身も大きく変わっています。
useQueryを使って、GETメソッドのgetDatesを呼び出しています。
引数として、queryKeyとqueryFnを指定し、それぞれ以下の意味を持ちます。

  • queryKey: クエリのキーを指定します。一意のクエリーキーを指定することで、キャッシュの管理ができます。
  • queryFn: クエリの関数を指定します。APIを呼び出す関数を指定します。

返り値としては、以下のような値が返ってきます。

  • data: 取得したデータ
  • isPending: ローディング中かどうか
  • isError: エラーかどうか
  • error: エラー内容
  • refetch: 再フェッチ
  • isFetching: フェッチ中かどうか

ここが大きなポイントで、APIで取得したデータを返すだけでなく、フェッチ中かどうか、エラーかどうか、エラー内容なども返ってきます。
これによって、useStateを使ってローディングなどの状態管理をする必要がなくなり、コード量も削減できます。

src/features/report/hooks/useFetchReportDates.ts
"use client"

import { currentDateAtom, yearDatesAtom } from "@/src/entities/report/model"
import { getDates } from "@/src/features/report/api/getDates"
import { useQuery } from "@tanstack/react-query"
import { useAtom } from "jotai"
import { useEffect } from "react"

export const useFetchReportDates = () => {
	const [, setYearDatesAtom] = useAtom(yearDatesAtom)
	const [currentDate] = useAtom(currentDateAtom)

	const { data, isPending, isError, error, refetch, isFetching } = useQuery({
		queryKey: ["reportDates", currentDate.year],
		queryFn: () => {
			if (!currentDate.year) {
				throw new Error("Year is required")
			}
			return getDates(currentDate.year)
		},
	})

	useEffect(() => {
		if (data) {
			setYearDatesAtom(data)
		}
	}, [data, setYearDatesAtom])

	return {
		data: data,
		isPending: isPending,
		isError: isError,
		error: error,
		refetch: refetch,
		isFetching: isFetching,
	}
}

カスタムフックのuseFetchReportDatesは修正までは、Providerで呼び出していましたが、修正後は、サイドバーのコンポーネント側で呼び出しています。
useFetchReportDatesの返り値である、isPendingやisErrorなどを使って、ローディングやエラーを表示しています。

src/widgets/sidebar/ui/Sidebar.tsx
"use client"

import { useFetchReportDates } from "@/src/features/report/model/useFetchReportDates"
import { SelectDate, SelectYear } from "@/src/features/select-date"
import type React from "react"

export const Sidebar: React.FC = () => {
	const { isPending, isError, error } = useFetchReportDates()

	if (isPending) return <div>読み込み中...</div>
	if (isError) return <div>エラー: {error?.message}</div>

	return (
		<div className="w-64 h-screen bg-gray-100 overflow-y-auto flex flex-col">
			<SelectYear />
			<SelectDate />
		</div>
	)
}

TanStack Queryを使うことで、useStateを使ってローディングなどの状態管理をする必要がなくなり、コード量も削減できました。

終わりに

TanStack Queryを使って、API呼び出し処理を書き換えてみました。
状態管理を別途するコードを書く必要がなくなり、コード量も削減できました。
今回は、GETメソッドのAPIを呼び出すケースでしたが、POST・メソッドの場合は、useMutationを使います。
それはまた時間があれば投稿したいと思います。

Discussion