tanstack-query について
tanstack-queryは、ReactアプリでTanStack Query v5を使うためのスキルです。設定、データ取得、キャッシュ、ミューテーション、デバッグに加え、移行時によくある問題の回避方法を扱います。
主な機能
- 01QueryClientとプロバイダーの設定
- 02クエリとミューテーションの実装
- 03キャッシュと無効化の管理
- 04楽観的更新の利用
- 05React Query DevToolsでのデバッグ
- 06v5移行時によくある問題の回避
活用シーン
- 01Reactアプリにサーバー状態の取得を追加
- 02キャッシュを更新するミューテーション処理の実装
- 03開発中のクエリのデバッグ
- 04TanStack Query v5の用法にコードを移行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jackspace/ claudeskillz --skill tanstack-queryインストール後、Claude Code で/tanstack-queryを入力して読み込まれたか確認します。
同梱ファイルREADME.mdSKILL.json
詳細
TanStack Query(React Query)v5
ステータス:本番環境で利用可能 ✅ 最終更新日:2025-10-22 依存関係:React 18.0+、TypeScript 4.7+(推奨) 最新バージョン:@tanstack/[email protected]、@tanstack/[email protected]
クイックスタート(5 分)
1. 依存関係をインストールする
npm install @tanstack/react-query@latest
npm install -D @tanstack/react-query-devtools@latest
重要な理由:
- TanStack Query v5 には React 18+ が必要です(useSyncExternalStore を使用します)
- DevTools はクエリとミューテーションのデバッグに不可欠です
- v5 には v4 からの破壊的変更があります。すべての修正が反映された最新版を使用してください
2. QueryClient Provider を設定する
// src/main.tsx or src/index.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60, // 1 hour (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)
重要:
- アプリ全体を
QueryClientProviderでラップしてください - 過剰な再フェッチを避けるため、
staleTimeを設定してください(デフォルトは 0 です) cacheTimeではなくgcTimeを使用してください(v5 で名称が変更されました)- DevTools は Provider の内側に配置してください
3. 最初のクエリを作成する
// src/hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query'
type Todo = {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error('Failed to fetch todos')
}
return response.json()
}
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
// Usage in component:
function TodoList() {
const { data, isPending, isError, error } = useTodos()
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
重要:
- v5 ではオブジェクト構文が必要です:
useQuery({ queryKey, queryFn }) isLoadingではなくisPendingを使用してください(isLoadingは現在、「保留中かつフェッチ中」を意味します)- 適切なエラー処理のため、queryFn では必ずエラーをスローしてください
- 一貫性のあるキャッシュキーを使うため、QueryKey は必ず配列にしてください
4. 最初のミューテーションを作成する
// src/hooks/useAddTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
type NewTodo = {
title: string
}
async function addTodo(newTodo: NewTodo) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to add todo')
return response.json()
}
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Invalidate and refetch todos
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
// Usage in component:
function AddTodoForm() {
const { mutate, isPending } = useAddTodo()
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
mutate({ title: formData.get('title') as string })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add Todo'}
</button>
</form>
)
}
この方法が機能する理由:
- ミューテーションではコールバック(
onSuccess、onError、onSettled)を使用しますが、クエリでは使用しません invalidateQueriesはバックグラウンドでの再フェッチを開始します- ミューテーションはデフォルトではキャッシュされません(正しい動作です)
7 ステップのセットアップ手順
ステップ 1:依存関係をインストールする
# Core library (required)
npm install @tanstack/react-query
# DevTools (highly recommended for development)
npm install -D @tanstack/react-query-devtools
# Optional: ESLint plugin for best practices
npm install -D @tanstack/eslint-plugin-query
各パッケージの役割:
@tanstack/react-query- React のコアフックと QueryClient@tanstack/react-query-devtools- 視覚的なデバッガー(開発時のみ、ツリーシェイキング可能)@tanstack/eslint-plugin-query- よくある間違いを検出します
バージョン要件:
- React 18.0 以上(
useSyncExternalStoreを使用します) - より良い型推論には TypeScript 4.7+ が必要です(任意ですが推奨)
ステップ 2:QueryClient を設定する
// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// How long data is considered fresh (won't refetch during this time)
staleTime: 1000 * 60 * 5, // 5 minutes
// How long inactive data stays in cache before garbage collection
gcTime: 1000 * 60 * 60, // 1 hour (v5: renamed from cacheTime)
// Retry failed requests (0 on server, 3 on client by default)
retry: (failureCount, error) => {
if (error instanceof Response && error.status === 404) return false
return failureCount < 3
},
// Refetch on window focus (can be annoying during dev)
refetchOnWindowFocus: false,
// Refetch on network reconnect
refetchOnReconnect: true,
// Refetch on component mount if data is stale
refetchOnMount: true,
},
mutations: {
// Retry mutations on failure (usually don't want this)
retry: 0,
},
},
})
主な設定項目:
staleTime と gcTime:
staleTime:データが「古い」とみなされ、再フェッチされる可能性が生じるまでの時間0(デフォルト):データはすぐに古くなり、マウント時やフォーカス時に再フェッチされます1000 * 60 * 5:データは 5 分間新鮮な状態に保たれ、その間は再フェッチされませんInfinity:データは古くならず、手動での無効化のみが行われます
gcTime:未使用のデータがキャッシュに保持される時間1000 * 60 * 5(デフォルト):5 分Infinity:ガベージコレクションが行われません(メモリーリークのリスクがあります)
再フェッチのタイミング:
refetchOnWindowFocus: true- 頻繁に変化するデータ(株価など)に適していますrefetchOnWindowFocus: false- 安定したデータや開発中に適していますrefetchOnMount: true- コンポーネントのマウント時に最新のデータを確保しますrefetchOnReconnect: true- ネットワーク再接続後に再フェッチします
ステップ 3:アプリを Provider でラップする
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from './lib/query-client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
/>
</QueryClientProvider>
</StrictMode>
)
Provider の配置:
- TanStack Query のフックを使用するすべてのコンポーネントをラップする必要があります
- DevTools は Provider の内側に配置する必要があります
- アプリ全体で QueryClient のインスタンスは 1 つだけにしてください
DevTools の設定:
initialIsOpen={false}- デフォルトでは折りたたまれた状態にしますbuttonPosition="bottom-right"- 切り替えボタンの表示位置を指定します- 本番ビルドでは自動的に削除されます(ツリーシェイキングされます)
ステップ 4:カスタムクエリフックを作成する
パターン:再利用可能なクエリフック
// src/api/todos.ts - API functions
export type Todo = {
id: number
title: string
completed: boolean
}
export async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`Failed to fetch todos: ${response.statusText}`)
}
return response.json()
}
export async function fetchTodoById(id: number): Promise<Todo> {
const response = await fetch(`/api/todos/${id}`)
if (!response.ok) {
throw new Error(`Failed to fetch todo ${id}: ${response.statusText}`)
}
return response.json()
}
// src/hooks/useTodos.ts - Query hooks
import { useQuery, queryOptions } from '@tanstack/react-query'
import { fetchTodos, fetchTodoById } from '../api/todos'
// Query options factory (v5 pattern for reusability)
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1 minute
})
export function useTodos() {
return useQuery(todosQueryOptions)
}
export function useTodo(id: number) {
return useQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodoById(id),
enabled: !!id, // Only fetch if id is truthy
})
}
queryOptions ファクトリーを使用する理由:
- 型推論が適切に機能します
useQuery、useSuspenseQuery、prefetchQuery間で再利用できます- どこでも一貫した queryKey と queryFn を使用できます
- テストと保守が容易になります
クエリキーの構造:
['todos']- すべての todo の一覧['todos', id]- 単一の todo の詳細['todos', 'filters', { status: 'completed' }]- フィルターされた一覧- より具体的なキーは部分集合です(
['todos']を無効化すると、すべてのキーが無効化されます)
ステップ 5:楽観的更新でミューテーションを実装する
// src/hooks/useTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Todo } from '../api/todos'
type AddTodoInput = {
title: string
}
type UpdateTodoInput = {
id: number
completed: boolean
}
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newTodo: AddTodoInput) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to add todo')
return response.json()
},
// Optimistic update
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
{ id: Date.now(), ...newTodo, completed: false },
])
// Return context with snapshot
return { previousTodos }
},
// Rollback on error
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos)
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
export function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, completed }: UpdateTodoInput) => {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
})
if (!response.ok) throw new Error('Failed to update todo')
return response.json()
},
onSuccess: (updatedTodo) => {
// Update the specific todo in cache
queryClient.setQueryData<Todo>(['todos', updatedTodo.id], updatedTodo)
// Invalidate list to refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
export function useDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/todos/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete todo')
},
onSuccess: (_, deletedId) => {
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.filter(todo => todo.id !== deletedId)
)
},
})
}
楽観的更新のパターン:
onMutate:クエリをキャンセルし、古いデータのスナップショットを取得して、キャッシュを楽観的に更新しますonError:ミューテーションが失敗した場合、スナップショットの状態にロールバックしますonSettled:キャッシュがサーバーと一致するように再取得します
使用する場面:
- UX を向上させるために、UI ですぐにフィードバックを返したい場合
- リスクの低いミューテーション(ToDo の切り替え、いいねボタン)
- 重要なデータ(支払い、アカウント設定)には使用しないでください
ステップ 6:DevTools を設定する
// Already set up in main.tsx, but here are advanced options:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
position="bottom"
// Custom toggle button
toggleButtonProps={{
style: { marginBottom: '4rem' },
}}
// Custom panel styles
panelProps={{
style: { height: '400px' },
}}
// Only show in dev (already tree-shaken in production)
// But can add explicit check:
// {import.meta.env.DEV && <ReactQueryDevtools />}
/>
DevTools の機能:
- すべてのクエリとその状態を表示します
- クエリキャッシュの内容を確認します
- クエリを手動で再取得します
- 実行中のミューテーションを表示します
- クエリの依存関係を調べます
- デバッグ用に状態をエクスポートします
ステップ 7:エラーバウンダリーを設定する
// src/components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react'
import { QueryErrorResetBoundary, useQueryErrorResetBoundary } from '@tanstack/react-query'
type Props = { children: ReactNode }
type State = { hasError: boolean }
class ErrorBoundaryClass extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Wrapper with TanStack Query error reset
export function ErrorBoundary({ children }: Props) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundaryClass onReset={reset}>
{children}
</ErrorBoundaryClass>
)}
</QueryErrorResetBoundary>
)
}
// Usage with throwOnError option:
function useTodosWithErrorBoundary() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true, // Throw errors to error boundary
})
}
// Or conditional:
function useTodosConditionalError() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: (error, query) => {
// Only throw server errors, handle network errors locally
return error instanceof Response && error.status >= 500
},
})
}
エラー処理の方法:
- ローカル処理:クエリから
isErrorとerrorを使用します - グローバル処理:
throwOnErrorを指定したエラーバウンダリーを使用します - 併用:条件付きの
throwOnError関数を使用します - 集約処理:
QueryCacheのグローバルエラーハンドラーを使用します
重要なルール
必ず行うこと
✅ すべてのフックでオブジェクト構文を使用する
// v5 ONLY supports this:
useQuery({ queryKey, queryFn, ...options })
useMutation({ mutationFn, ...options })
✅ 配列形式のクエリキーを使用する
queryKey: ['todos'] // List
queryKey: ['todos', id] // Detail
queryKey: ['todos', { filter }] // Filtered
✅ staleTime を適切に設定する
staleTime: 1000 * 60 * 5 // 5 min - prevents excessive refetches
✅ 初回読み込み状態には isPending を使用する
if (isPending) return <Loading />
// isPending = no data yet AND fetching
✅ queryFn でエラーをスローする
if (!response.ok) throw new Error('Failed')
✅ ミューテーション後にクエリを無効化する
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
✅ 再利用可能なパターンには queryOptions ファクトリーを使用する
const opts = queryOptions({ queryKey, queryFn })
useQuery(opts)
useSuspenseQuery(opts)
prefetchQuery(opts)
✅ gcTime を使用する(cacheTime は使用しない)
gcTime: 1000 * 60 * 60 // 1 hour
絶対に行わないこと
❌ v4 の配列構文/関数構文を使用しない
// v4 (removed in v5):
useQuery(['todos'], fetchTodos, options) // ❌
// v5 (correct):
useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) // ✅
❌ クエリでクエリコールバック(onSuccess、onError、onSettled)を使用しない
// v5 removed these from queries:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {}, // ❌ Removed in v5
})
// Use useEffect instead:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
// Do something
}
}, [data])
// Or use mutation callbacks (still supported):
useMutation({
mutationFn: addTodo,
onSuccess: () => {}, // ✅ Still works for mutations
})
❌ 非推奨のオプションを使用しない
// Deprecated in v5:
cacheTime: 1000 // ❌ Use gcTime instead
isLoading: true // ❌ Meaning changed, use isPending
keepPreviousData: true // ❌ Use placeholderData instead
onSuccess: () => {} // ❌ Removed from queries
useErrorBoundary: true // ❌ Use throwOnError instead
❌ isLoading が「まだデータがない」という意味だと決めつけない
// v5 changed this:
isLoading = isPending && isFetching // ❌ Now means "pending AND fetching"
isPending = no data yet // ✅ Use this for initial load
❌ 無限クエリで initialPageParam を設定し忘れない
// v5 requires this:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // ✅ Required in v5
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
❌ useSuspenseQuery で enabled を使用しない
// Not allowed:
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id, // ❌ Not available with suspense
})
// Use conditional rendering instead:
{id && <TodoComponent id={id} />}
既知の問題の予防
このスキルは、v5 への移行時やよくある間違いに関する文書化された 8 件の問題を防ぎます。
問題 #1:オブジェクト構文が必須
エラー:useQuery is not a function または型エラー
出典:v5 Migration Guide
発生理由:v5 ではすべての関数オーバーロードが削除され、オブジェクト構文のみが機能します
予防策:常に useQuery({ queryKey, queryFn, ...options }) を使用します
変更前(v4):
useQuery(['todos'], fetchTodos, { staleTime: 5000 })
変更後(v5):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000
})
問題 #2:クエリコールバックの削除
エラー:コールバックが実行されない、TypeScript エラー
出典:v5 Breaking Changes
発生理由:クエリから onSuccess、onError、onSettled が削除されました(ミューテーションでは引き続き機能します)
予防策:副作用には useEffect を使用するか、ロジックをミューテーションのコールバックに移動します
変更前(v4):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {
console.log('Todos loaded:', data)
},
})
変更後(v5):
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
console.log('Todos loaded:', data)
}
}, [data])
問題 #3:ステータスの loading → pending
エラー:UI の読み込み状態が正しく表示されない
出典:v5 Migration: isLoading renamed
発生理由:status: 'loading' が status: 'pending' に改称され、isLoading の意味も変更されました
予防策:初回読み込みには isPending を使用し、「pending かつ fetching」の状態には isLoading を使用します
変更前(v4):
const { data, isLoading } = useQuery(...)
if (isLoading) return <div>Loading...</div>
変更後(v5):
const { data, isPending, isLoading } = useQuery(...)
if (isPending) return <div>Loading...</div>
// isLoading = isPending && isFetching (fetching for first time)
問題 #4:cacheTime → gcTime
エラー:cacheTime is not a valid option
出典:v5 Migration: gcTime
発生理由:「ガベージコレクション時間」をより適切に表す名前に変更されました
予防策:cacheTime の代わりに gcTime を使用します
変更前(v4):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
cacheTime: 1000 * 60 * 60,
})
変更後(v5):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 60,
})
Issue #5:useSuspenseQuery + enabled
エラー:型エラー。enabled オプションは使用できません
ソース:GitHub ディスカッション #6206
発生する理由:Suspense はデータが利用可能であることを保証するため、条件付きで無効にできません
予防策:enabled オプションではなく、条件付きレンダリングを使用します
変更前(v4/誤り):
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id, // ❌ Not allowed
})
変更後(v5/正しい):
// Conditional rendering:
{id ? (
<TodoComponent id={id} />
) : (
<div>No ID selected</div>
)}
// Inside TodoComponent:
function TodoComponent({ id }: { id: number }) {
const { data } = useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
// No enabled option needed
})
return <div>{data.title}</div>
}
Issue #6:initialPageParam が必須
エラー:initialPageParam is required という型エラー
ソース:v5 への移行:無限クエリ
発生する理由:v4 では最初の pageParam として undefined が渡されていましたが、v5 では明示的な値が必要です
予防策:無限クエリでは必ず initialPageParam を指定します
変更前(v4):
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
変更後(v5):
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // ✅ Required
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
Issue #7:keepPreviousData が削除
エラー:keepPreviousData is not a valid option
ソース:v5 への移行:placeholderData
発生する理由:より柔軟な placeholderData 関数に置き換えられました
予防策:placeholderData: keepPreviousData ヘルパーを使用します
変更前(v4):
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
keepPreviousData: true,
})
変更後(v5):
import { keepPreviousData } from '@tanstack/react-query'
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
})
Issue #8:TypeScript のエラー型のデフォルト
エラー:エラー処理に関する型エラー
ソース:v5 への移行:エラー型
発生する理由:v4 では unknown が使用されていましたが、v5 ではデフォルトで Error 型になります
予防策:Error 型以外をスローする場合は、エラー型を明示的に指定します
変更前(v4 - エラーは unknown):
const { error } = useQuery({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw 'custom error string'
return data
},
})
// error: unknown
変更後(v5 - カスタムエラー型を指定):
const { error } = useQuery<DataType, string>({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw 'custom error string'
return data
},
})
// error: string | null
// Or better: always throw Error objects
const { error } = useQuery({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw new Error('custom error')
return data
},
})
// error: Error | null (default)
設定ファイルのリファレンス
package.json(完全な例)
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.90.5"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/eslint-plugin-query": "^5.90.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.1"
}
}
これらのバージョンを使用する理由:
- React 18.3.1 - useSyncExternalStore に必要です
- TanStack Query 5.90.5 - v5 のすべての修正を含む最新の安定版です
- DevTools 5.90.2 - query のバージョンに合わせています
- TypeScript 5.6.3 - query の型を最適に推論します
tsconfig.json(TypeScript の設定)
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* TanStack Query specific */
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src"]
}
.eslintrc.cjs(TanStack Query プラグインを使用した ESLint の設定)
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh', '@tanstack/query'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
ESLint プラグインで検出できる項目:
- クエリキーをインラインではなく参照として使用している
- queryFn がない
- v5 で v4 のパターンを使用している
- useEffect の依存関係が正しくない
よく使われるパターン
パターン 1:依存クエリ
// Fetch user, then fetch user's posts
function UserPosts({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Only fetch posts after user is loaded
})
if (!user) return <div>Loading user...</div>
if (!posts) return <div>Loading posts...</div>
return (
<div>
<h1>{user.name}</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
使用する場面:クエリ B がクエリ A のデータに依存する場合
パターン 2:useQueries を使用した並列クエリ
// Fetch multiple todos in parallel
function TodoDetails({ ids }: { ids: number[] }) {
const results = useQueries({
queries: ids.map(id => ({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
})),
})
const isLoading = results.some(result => result.isPending)
const isError = results.some(result => result.isError)
if (isLoading) return <div>Loading...</div>
if (isError) return <div>Error loading todos</div>
return (
<ul>
{results.map((result, i) => (
<li key={ids[i]}>{result.data?.title}</li>
))}
</ul>
)
}
使用する場面:互いに独立した複数のクエリを並列で取得する場合
パターン 3:プリフェッチ
import { useQueryClient } from '@tanstack/react-query'
import { todosQueryOptions } from './hooks/useTodos'
function TodoListWithPrefetch() {
const queryClient = useQueryClient()
const { data: todos } = useTodos()
const prefetchTodo = (id: number) => {
queryClient.prefetchQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
staleTime: 1000 * 60 * 5, // 5 minutes
})
}
return (
<ul>
{todos?.map(todo => (
<li
key={todo.id}
onMouseEnter={() => prefetchTodo(todo.id)}
>
<Link to={`/todos/${todo.id}`}>{todo.title}</Link>
</li>
))}
</ul>
)
}
使用する場面:ユーザーが移動する前(ホバー時やマウント時)にデータを事前読み込みする場合
パターン 4:無限スクロール
import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
type Page = {
data: Todo[]
nextCursor: number | null
}
async function fetchTodosPage({ pageParam }: { pageParam: number }): Promise<Page> {
const response = await fetch(`/api/todos?cursor=${pageParam}&limit=20`)
return response.json()
}
function InfiniteTodoList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: fetchTodosPage,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const loadMoreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 }
)
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage])
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.data.map(todo => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
))}
<div ref={loadMoreRef}>
{isFetchingNextPage && <div>Loading more...</div>}
</div>
</div>
)
}
使用する場面:無限スクロールに対応したページ分割リストの場合
パターン 5:クエリのキャンセル
function SearchTodos() {
const [search, setSearch] = useState('')
const { data } = useQuery({
queryKey: ['todos', 'search', search],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/todos?q=${search}`, { signal })
return response.json()
},
enabled: search.length > 2, // Only search if 3+ characters
})
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search todos..."
/>
{data && (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)}
</div>
)
}
仕組み:
- queryKey が変更されると、前のクエリは自動的にキャンセルされます
- 適切にクリーンアップするため、fetch に
signalを渡します - ブラウザーが保留中の fetch リクエストを中止します
同梱リソースの使用
テンプレート(templates/)
そのままコピーして使えるコード例です:
package.json- 正確なバージョンを指定した依存関係query-client-config.ts- ベストプラクティスに沿った QueryClient のセットアップprovider-setup.tsx- QueryClientProvider を使ったアプリのラッパーuse-query-basic.tsx- useQuery フックの基本パターンuse-mutation-basic.tsx- useMutation フックの基本パターンuse-mutation-optimistic.tsx- 楽観的更新のパターンuse-infinite-query.tsx- 無限スクロールのパターンcustom-hooks-pattern.tsx- queryOptions を使った再利用可能なクエリフックerror-boundary.tsx- クエリのリセット機能を備えたエラーバウンダリーdevtools-setup.tsx- DevTools の設定
使用例:
# Copy query client config
cp ~/.claude/skills/tanstack-query/templates/query-client-config.ts src/lib/
# Copy provider setup
cp ~/.claude/skills/tanstack-query/templates/provider-setup.tsx src/main.tsx
参考資料(references/)
必要に応じて読み込む詳細ドキュメント:
v4-to-v5-migration.md- v4 → v5 の完全な移行ガイドbest-practices.md- リクエストのウォーターフォール、キャッシュ戦略、パフォーマンスcommon-patterns.md- 再利用可能なクエリ、楽観的更新、無限スクロールtypescript-patterns.md- 型安全性、ジェネリクス、型推論testing.md- MSW、React Testing Library を使ったテストtop-errors.md- 解決方法付きの8件以上のエラーすべて
Claude がこれらを読み込むタイミング:
v4-to-v5-migration.md- 既存の React Query v4 プロジェクトを移行するときbest-practices.md- パフォーマンスを最適化するとき、またはウォーターフォールを回避するときcommon-patterns.md- 特定の機能(無限スクロールなど)を実装するときtypescript-patterns.md- TypeScript のエラーや型推論に対処するときtesting.md- TanStack Query を使うコンポーネントのテストを書くときtop-errors.md- メインの SKILL.md に記載されていないエラーに遭遇したとき
高度なトピック
select によるデータ変換
// Only subscribe to specific slice of data
function TodoCount() {
const { data: count } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length, // Only re-render when count changes
})
return <div>Total todos: {count}</div>
}
// Transform data shape
function CompletedTodoTitles() {
const { data: titles } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) =>
data
.filter(todo => todo.completed)
.map(todo => todo.title),
})
return (
<ul>
{titles?.map((title, i) => (
<li key={i}>{title}</li>
))}
</ul>
)
}
利点:
- 選択したデータが変更された場合にのみコンポーネントが再レンダリングされます
- メモリ使用量が減ります(コンポーネントの状態に保存するデータが少なくなります)
- クエリキャッシュは変更されません(ほかのコンポーネントはすべてのデータを取得できます)
リクエストのウォーターフォール(アンチパターン)
// ❌ BAD: Sequential waterfalls
function BadUserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user!.id),
enabled: !!user,
})
const { data: comments } = useQuery({
queryKey: ['comments', posts?.[0]?.id],
queryFn: () => fetchComments(posts![0].id),
enabled: !!posts && posts.length > 0,
})
// Each query waits for previous one = slow!
}
// ✅ GOOD: Fetch in parallel when possible
function GoodUserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
// Fetch posts AND comments in parallel
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId), // Don't wait for user
})
const { data: comments } = useQuery({
queryKey: ['comments', userId],
queryFn: () => fetchUserComments(userId), // Don't wait for posts
})
// All 3 queries run in parallel = fast!
}
サーバー状態とクライアント状態
// ❌ Don't use TanStack Query for client-only state
const { data: isModalOpen, setData: setIsModalOpen } = useMutation(...)
// ✅ Use useState for client state
const [isModalOpen, setIsModalOpen] = useState(false)
// ✅ Use TanStack Query for server state
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
目安:
- サーバー状態:TanStack Query を使います(API から取得するデータ)
- クライアント状態:useState/useReducer を使います(ローカルの UI 状態)
- グローバルなクライアント状態:Zustand/Context を使います(テーマ、認証トークン)
依存関係
必須:
@tanstack/[email protected]- コアライブラリ[email protected]+- useSyncExternalStore フックを使用します[email protected]+- React DOM レンダラー
推奨:
@tanstack/[email protected]- ビジュアルデバッガー(開発時のみ)@tanstack/[email protected]- ベストプラクティスのための ESLint ルール[email protected]+- 型安全性と型推論のため
任意:
@tanstack/query-sync-storage-persister- キャッシュを localStorage に永続化@tanstack/query-async-storage-persister- キャッシュを AsyncStorage に永続化(React Native)
公式ドキュメント
- TanStack Query ドキュメント:https://tanstack.com/query/latest
- React インテグレーション:https://tanstack.com/query/latest/docs/framework/react/overview
- v5 移行ガイド:https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5
- API リファレンス:https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
- Context7 ライブラリ ID:
/websites/tanstack_query - GitHub リポジトリ:https://github.com/TanStack/query
- Discord コミュニティ:https://tlinz.com/discord
パッケージのバージョン(2025-10-22 に確認)
{
"dependencies": {
"@tanstack/react-query": "^5.90.5"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/eslint-plugin-query": "^5.90.2"
}
}
確認内容:
npm view @tanstack/react-query version→ 5.90.5npm view @tanstack/react-query-devtools version→ 5.90.2- 最終確認日:2025-10-22
本番環境での例
このスキルは、以下の本番環境で使われるパターンに基づいています:
- 構築時間:調査と開発に約6時間
- 防止したエラー:8件(文書化された v5 移行時の問題すべて)
- トークン効率:手動セットアップと比べて約65%削減
- 検証:✅ すべてのパターンを TypeScript の strict mode でテスト済み
トラブルシューティング
問題:「useQuery is not a function」または型エラー
解決方法:v5 のオブジェクト構文を使用していることを確認します:
// ✅ Correct:
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// ❌ Wrong (v4 syntax):
useQuery(['todos'], fetchTodos)
問題:クエリでコールバック(onSuccess、onError)が動作しない
解決方法:v5 で削除されました。useEffect を使うか、ミューテーションに移します:
// ✅ For queries:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
// Handle success
}
}, [data])
// ✅ For mutations (still work):
useMutation({
mutationFn: addTodo,
onSuccess: () => { /* ... */ },
})
問題:初回読み込み中でも isLoading が常に false になる
解決方法:代わりに isPending を使います:
const { isPending, isLoading, isFetching } = useQuery(...)
// isPending = no data yet
// isLoading = isPending && isFetching
// isFetching = any fetch in progress
問題:cacheTime オプションが認識されない
解決方法:v5 では gcTime に名前が変更されました:
gcTime: 1000 * 60 * 60 // 1 hour
問題:useSuspenseQuery で enabled オプションを使うと型エラーになる
解決方法:suspense では enabled を使用できません。条件付きレンダリングを使います:
{id && <TodoComponent id={id} />}
問題:ミューテーション後にデータが再取得されない
解決方法:onSuccess でクエリを無効化します:
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
問題:無限クエリで initialPageParam が必要です
解決方法:v5 では必ず initialPageParam を指定してください:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // Required
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
問題:keepPreviousData が機能しません
解決方法:placeholderData に置き換えられました:
import { keepPreviousData } from '@tanstack/react-query'
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
})
セットアップ完了チェックリスト
このチェックリストを使って、セットアップを確認してください:
- @tanstack/[email protected]+ をインストールした
- @tanstack/react-query-devtools をインストールした(開発依存関係)
- デフォルト値を設定した QueryClient を作成した
- QueryClientProvider でアプリをラップした
- ReactQueryDevtools コンポーネントを追加した
- オブジェクト構文を使って最初のクエリを作成した
- isPending とエラー状態をテストした
- onSuccess ハンドラーを使って最初のミューテーションを作成した
- ミューテーション後のクエリ無効化を設定した
- staleTime と gcTime を適切に設定した
- 一貫して配列形式の queryKey を使用している
- queryFn 内でエラーをスローしている
- v4 の構文(関数オーバーロード)を使用していない
- クエリコールバック(クエリでの onSuccess、onError)を使用していない
- 初回読み込みには isLoading ではなく isPending を使用している
- 開発環境で DevTools が動作している
- TypeScript の型が正しく機能している
- 本番ビルドが成功する
質問や問題がある場合
- 完全なエラー解決方法については
references/top-errors.mdを確認してください - セットアップ手順をすべて確認してください
- 公式ドキュメントを確認してください:https://tanstack.com/query/latest
- v5 の構文(オブジェクト構文、gcTime、isPending)を使用していることを確認してください
- Discord に参加してください:https://tlinz.com/discord