KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

TanStack Queryを安全に書く—queryOptionsとskipToken—

この記事は、 テックブログ強化月間 リレーブログ企画2026 参加の記事です。

こんにちは!フロントエンドエンジニアのY.Kです! 今回はTanStack Query v5で使っている、「安全性を少し高めるための小ネタ」を2つ紹介します。 どれも今のコードを大きく変えずに試せるものばかりです。

queryOptions—定義を1箇所に集約する

よくやる書き方

同じオプションをuseQueryprefetchQueryの両方で使う場面、ありますよね。素直に書くと、だいたい次のような形になると思います。

// useQuery 側
useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
})

// prefetch 側
queryClient.prefetchQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
})

見た目はすっきりしていますが、queryKeyとqueryFnをそれぞれで定義している点には少し注意が必要です。 たとえば片方だけ修正し忘れてしまうと、意図しないキャッシュの不整合が起きる可能性があります。 しかもこのケース、型エラーも出ずビルドも通ってしまうため、問題に気づくのが難しいこともあります。

queryOptions でまとめる

こうした重複を避けたいときに便利なのがqueryOptionsです。 クエリの定義を1 箇所に集約できます。

import { queryOptions } from '@tanstack/react-query'
import { fetchUser } from '../api/user'

const userOptions = queryOptions({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
})

useQuery(userOptions)
queryClient.prefetchQuery(userOptions)
queryClient.getQueryData(userOptions.queryKey) // 特定のプロパティだけ指定することも可能

さらに、id のように動的な値を扱いたい場合は、関数として定義すると使い回しやすくなります。

import { queryOptions } from '@tanstack/react-query'
import { fetchUser } from '../api/user'

const userQueryOptions = (id: string) =>
  queryOptions({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  })

useQuery(userQueryOptions(userId))
queryClient.prefetchQuery(userQueryOptions(userId))
queryClient.getQueryData(userQueryOptions(userId).queryKey)

これにより、すべての呼び出しが 同じクエリ定義を参照することになるため、queryKeyの変更漏れのような事故を自然と防ぎやすくなります。

skipToken — queryFnに型安全性を

よくやる書き方

enabledプロパティを使った条件付きフェッチ、よく書きますよね。

useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  enabled: !!id,
})

ここで、fetchUserが次のような定義だった場合を考えてみます。

const fetchUser = async(id: string): Promise<User>=> 
  await fetch("api/user").then(res => res.json())

id が string | undefined のような型だと、そのままでは型エラーになります。 そのため、次のように型アサーションで回避するケースも多いと思います。

useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id!),
  enabled: !!id,
})

useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id as string),
  enabled: !!id,
})

enabled がfalseのときは実行されないため、実際に問題が起きることはほとんどありません。 ただし、queryFnの型安全性やコードの見通しという観点では、あまり気持ちのいい書き方とは言えないでしょう。

skipTokenを返す

ここで使えるのがskipTokenです。 これを使うと、型レベルで「このケースではクエリを実行しない」ことを表現できます。

const fetchUser = async(id: string): Promise<User>=> 
  await fetch(`api/user${id}`).then(res => res.json())

useQuery({
  queryKey: ['user', id],
  queryFn: id ? () => fetchUser(id) : skipToken,
})

idがfalsyなときにskipTokenを返すだけで、

  • enabledを使った場合と同じ挙動になる
  • queryFnに無理な型アサーションを書かずに済む

というメリットがあります。 結果として、queryFnの型安全性とコードの見通しをそのまま保てるのが大きなポイントです。

コラム: queryFnasync は必須でない

ここまでの例で登場した queryFn ですが、実は async / await を付けることは必須ではありません

// どちらでもOK
queryFn: async () => await fetchUser(id),
queryFn: () => fetchUser(id), 

というのもTanStack Queryの内部実装で、queryFnはPromise.resolveでラップされており、そのまま解決されるようになっています。

個人的には以下のような場合ではasync awaitを付けるようにしています。

queryFn: async () => {
    const response = await fetchUser(id)
    if (!response.ok) throw new Error() // fetch以外のエラー処理などを挟む
    return response
  }

単純にPromiseを返すだけであれば、余計なasyncを付けずにそのまま返すのも一つの選択肢です。 コードを少しだけすっきりさせたいときに思い出すと便利な小ネタですね。

まとめ

今回はqueryOptionsskipTokenの2つをご紹介しました。 どちらも少し工夫するだけで、安全性を高めつつ、コードの見通しをよくできるのが特徴です。 クエリ定義の重複や、条件付きフェッチ周りの小さなモヤっと感は、こうした仕組みを使うことで自然と減らせます。 結果として、レビューや保守もやりやすくなるはずです。 まずは使えそうなところから、少しずつ取り入れてみてください。

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp