TanStack Query v5のqueryOptionsが便利! クエリ定義をすっきりまとめる方法

はじめに

TanStack Queryを使っていると、最初は気にならなくても、少し画面が増えたあたりから同じ定義の重複が目立ってきます。useQueryではこのqueryKey、プリフェッチでは少し違う設定、といった小さなズレが積み重なると、後から直すのがじわじわ面倒です。

そこで使いやすいのが、v5のqueryOptionsです。クエリの基本定義を1か所に寄せておけるので、一覧画面、詳細画面、プリフェッチのあいだで設定を揃えやすくなります。

今回は、queryOptionsの基本形から、実務でありがちな「詳細取得と先読みを同じ定義で回したい」場面までをまとめます。React Queryを日常的に使っている人ほど、地味に効く改善だと思います。

queryOptionsで何がうれしいのか

queryOptionsは、useQueryqueryClient.prefetchQueryなどで共通利用できるオプションをまとめるためのヘルパーです。v5ではフック呼び出しがオブジェクト形式に揃っているので、この書き方とかなり相性がいいです。

うれしいのは、見た目がきれいになることだけではありません。queryKeyqueryFnstaleTimeのような土台の設定をまとめておくことで、TypeScript上の型も揃えやすくなります。

特に効いてくるのは、次のようなケースです。

  • 同じデータを詳細画面と一覧画面の両方で使う
  • 遷移前にプリフェッチしたい
  • invalidateするキーの書き間違いを減らしたい

まずは基本の形を見てみる

最初は、商品詳細を取得する定義を1つに寄せるシンプルな例です。ここでは「データ取得の中身を1か所にまとめる」感覚だけつかめれば十分です。

JSX
import { queryOptions, useQuery } from '@tanstack/react-query'

type Product = {
  id: string
  name: string
  price: number
}

async function fetchProduct(productId: string): Promise<Product> {
  const res = await fetch(`/api/products/${productId}`)

  if (!res.ok) {
    throw new Error('Failed to fetch product')
  }

  return res.json()
}

function productQueryOptions(productId: string) {
  return queryOptions({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    staleTime: 1000 * 30,
  })
}

export function ProductDetail({ productId }: { productId: string }) {
  const { data, isLoading, error } = useQuery(productQueryOptions(productId))

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>読み込みに失敗しました</p>

  return (
    <section>
      <h1>{data.name}</h1>
      <p>{data.price}</p>
    </section>
  )
}

この時点で、クエリの土台はproductQueryOptionsにまとまっています。別の場所で同じ商品データを先読みしたいときも、この関数をそのまま使えば済みます。

見た目は小さな整理ですが、同じqueryKeyqueryFnを何度も書かなくてよくなるだけでも、あとから効いてきます。

実務ではこう使うと効きやすい

実務では、ルーティング前のプリフェッチやキャッシュ更新まで含めて、同じ定義を回したくなることがよくあります。以下は一覧画面から詳細画面へ遷移する前に先読みする例です。

JSX
import {
  QueryClient,
  queryOptions,
  useQuery,
  useQueryClient,
} from '@tanstack/react-query'

type Product = {
  id: string
  name: string
  price: number
  stock: number
}

async function fetchProduct(productId: string): Promise<Product> {
  const res = await fetch(`/api/products/${productId}`)

  if (!res.ok) {
    throw new Error('Failed to fetch product')
  }

  return res.json()
}

function productQueryOptions(productId: string) {
  return queryOptions({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    staleTime: 1000 * 60,
    gcTime: 1000 * 60 * 10,
  })
}

export async function prefetchProduct(
  queryClient: QueryClient,
  productId: string,
) {
  await queryClient.prefetchQuery(productQueryOptions(productId))
}

export function ProductLink({ productId }: { productId: string }) {
  const queryClient = useQueryClient()

  return (
    <a
      href={`/products/${productId}`}
      onMouseEnter={() => {
        queryClient.prefetchQuery(productQueryOptions(productId))
      }}
    >
      商品詳細を見る
    </a>
  )
}

export function ProductDetail({ productId }: { productId: string }) {
  const { data } = useQuery(productQueryOptions(productId))
  const queryClient = useQueryClient()

  const markAsLowStock = async () => {
    await fetch(`/api/products/${productId}/mark-low-stock`, {
      method: 'POST',
    })

    queryClient.invalidateQueries({
      queryKey: productQueryOptions(productId).queryKey,
    })
  }

  return (
    <section>
      <h1>{data?.name}</h1>
      <p>在庫: {data?.stock}</p>
      <button onClick={markAsLowStock}>在庫状態を更新</button>
    </section>
  )
}

この形にしておくと、次の点がかなり揃いやすくなります。

  • queryKeyの命名がブレにくい
  • staleTimegcTimeの設定が画面ごとにズレにくい
  • プリフェッチと本取得で別実装になりにくい

一覧、詳細、モーダル、ホバー時先読みのように、同じデータへ複数の入口がある画面ほど効果が見えやすくなります。

「取得の定義は共通」「表示条件や見せ方は各コンポーネント側」という役割分担にすると、保守もしやすくなります。

注意点

  • 何でも1つの関数に詰め込まない
    UI専用のselectや、コンポーネント固有のenabledまで全部共通化すると、逆に読みにくくなります。まずはqueryKeyqueryFn、基本のキャッシュ設定くらいから始めるのが無難です。
  • queryKeyの設計は先に揃える
    ['product', id]なのか['products', id]なのかが揺れると、invalidateやgetQueryDataで混乱します。queryOptionsを作る前に、キー設計を軽く決めておくと後でかなり楽です。
  • デフォルト設定との重なりを把握する
    TanStack Queryは、初期状態でも再フェッチやキャッシュ保持に関する挙動があります。staleTimeだけ個別に足して終わりにすると、思ったより再取得されることもあります。
  • React RouterやNext.jsと組み合わせる時は責務を分ける
    ルートローダーやサーバーコンポーネント側で先読みする場合でも、データ定義そのものをqueryOptionsへ寄せておくと整理しやすいです。画面側は「どう表示するか」に集中しやすくなります。

まとめ

TanStack Query v5のqueryOptionsは、単なる書き方の小ネタではなく、クエリ定義の置き場所を整えるための実務向けの仕組みです。特に、同じデータを複数の画面やタイミングで使うアプリでは効果が見えやすくなります。

最初から完璧に共通化しようとせず、詳細取得のような分かりやすいところから切り出すのがおすすめです。queryKeyqueryFnを1か所にまとめるだけでも、修正コストはかなり下がります。

ポイント

  • 詳細取得とプリフェッチを同じ定義で回したいときに特に便利
  • まずはqueryKeyqueryFnだけでも共通化する価値がある
  • UIごとの条件まで詰め込みすぎず、役割を分けると長く運用しやすい

参考リンク

read next