tanstack-query について
TanStack Query は、React アプリのサーバー状態を管理するライブラリです。リモートデータの取得、キャッシュ、同期を行う開発者向けに、クエリ、ミューテーション、ページネーション、楽観的更新などを提供します。
主な機能
- 01サーバーデータのキャッシュとバックグラウンド再取得
- 02クエリとミューテーションの状態管理
- 03ページネーションと無限クエリ
- 04楽観的更新とクエリの無効化
- 05SSR、水和、永続化
活用シーン
- 01アプリのリモートデータをキャッシュして同期
- 02ページネーションや無限スクロールのリストを構築
- 03ミューテーション後に画面を即時更新
- 04SSR ページ用のデータを事前取得して水和
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ tanstack-skills/ tanstack-skills --skill tanstack-queryインストール後、Claude Code で/tanstack-queryを入力して読み込まれたか確認します。
詳細
概要
TanStack Query(旧称 React Query)はサーバー状態を管理します。サーバー上にあり、取得、キャッシュ、同期、更新が必要なデータです。自動キャッシュ、バックグラウンドでの再取得、stale-while-revalidate パターン、ページネーション、無限スクロール、楽観的更新を標準で提供します。
パッケージ: @tanstack/react-query
開発ツール: @tanstack/react-query-devtools
現在のバージョン: v5
インストール
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools # Optional
セットアップ
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection)
retry: 3,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
基本概念
クエリーキー
クエリーキーはキャッシュされたデータを一意に識別します。シリアライズ可能な配列である必要があります。
// Simple key
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// With variables (dependency array pattern)
useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos })
// Hierarchical keys for invalidation
useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) })
useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) })
// Invalidation matches prefixes:
// queryClient.invalidateQueries({ queryKey: ['todos'] })
// ^ Invalidates ALL queries starting with 'todos'
クエリー関数
// Query function receives a QueryFunctionContext
useQuery({
queryKey: ['todos', todoId],
queryFn: async ({ queryKey, signal, meta }) => {
const [_key, id] = queryKey
const response = await fetch(`/api/todos/${id}`, { signal })
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
},
})
// Using the signal for automatic cancellation
useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/todos', { signal })
return response.json()
},
})
queryOptions ヘルパー
再利用可能で型安全なクエリー設定を作成します。
import { queryOptions } from '@tanstack/react-query'
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
})
export const todoQueryOptions = (todoId: string) =>
queryOptions({
queryKey: ['todos', todoId],
queryFn: () => fetchTodo(todoId),
enabled: !!todoId,
})
// Usage
const { data } = useQuery(todosQueryOptions)
const { data } = useSuspenseQuery(todoQueryOptions(id))
await queryClient.prefetchQuery(todosQueryOptions)
クエリー(useQuery)
基本的な使い方
import { useQuery } from '@tanstack/react-query'
function Todos() {
const {
data,
error,
isLoading, // First load, no data yet
isFetching, // Any fetch in progress (including background)
isError,
isSuccess,
isPending, // No data yet (same as isLoading in most cases)
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
refetch,
isStale,
isPlaceholderData,
dataUpdatedAt,
errorUpdatedAt,
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isLoading) return <Spinner />
if (isError) return <Error message={error.message} />
return <TodoList todos={data} />
}
クエリーオプション
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// Freshness
staleTime: 5000, // ms data stays fresh (default: 0)
gcTime: 300000, // ms unused data stays in cache (default: 5 min)
// Refetching
refetchInterval: 10000, // Poll every 10s
refetchIntervalInBackground: false, // Don't poll when tab hidden
refetchOnMount: true, // Refetch on component mount if stale
refetchOnWindowFocus: true, // Refetch on window focus if stale
refetchOnReconnect: true, // Refetch on network reconnect
// Retry
retry: 3, // Number of retries (or function)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Conditional
enabled: !!userId, // Only run when truthy
// Initial/placeholder data
initialData: () => cachedData,
initialDataUpdatedAt: Date.now() - 10000,
placeholderData: (previousData) => previousData, // keepPreviousData pattern
placeholderData: initialTodos,
// Transform
select: (data) => data.filter(todo => !todo.done),
// Structural sharing (default: true)
structuralSharing: true,
// Network mode
networkMode: 'online', // 'online' | 'always' | 'offlineFirst'
// Meta (accessible in query function context)
meta: { purpose: 'user-facing' },
})
ミューテーション(useMutation)
基本的な使い方
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(res => res.json())
},
// Lifecycle callbacks
onMutate: async (variables) => {
// Called before mutationFn
// Good for optimistic updates
return { previousTodos } // context for onError
},
onSuccess: (data, variables, context) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error, variables, context) => {
// Rollback optimistic updates
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: (data, error, variables, context) => {
// Always runs (success or error)
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<button
onClick={() => mutation.mutate({ title: 'New Todo' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
)
}
ミューテーションの状態
const {
mutate, // Fire-and-forget
mutateAsync, // Returns promise
isPending, // Mutation in progress
isError,
isSuccess,
isIdle, // Not yet fired
data, // Success response
error, // Error object
reset, // Reset state to idle
variables, // Variables passed to mutate
status, // 'idle' | 'pending' | 'error' | 'success'
} = useMutation({ ... })
楽観的更新
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 1. Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
// 2. Snapshot previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
// 3. Optimistically update
queryClient.setQueryData(['todos', newTodo.id], newTodo)
// 4. Return context for rollback
return { previousTodo }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos', newTodo.id], context.previousTodo)
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
リストの楽観的更新
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
クエリーの無効化
const queryClient = useQueryClient()
// Invalidate all queries
queryClient.invalidateQueries()
// Invalidate by prefix
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Invalidate exact match
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true })
// Invalidate with predicate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done',
})
// Invalidate and refetch immediately
queryClient.refetchQueries({ queryKey: ['todos'] })
// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['todos', 1] })
// Reset to initial state
queryClient.resetQueries({ queryKey: ['todos'] })
無限クエリー
import { useInfiniteQuery } from '@tanstack/react-query'
function InfiniteList() {
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/projects?cursor=${pageParam}`)
return res.json()
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.nextCursor ?? undefined // undefined = no more pages
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPage.prevCursor ?? undefined
},
maxPages: 3, // Keep max 3 pages in cache (for performance)
})
return (
<div>
{data.pages.map((page) =>
page.items.map((item) => <Item key={item.id} item={item} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
</button>
</div>
)
}
並列クエリー
// Multiple independent queries run in parallel automatically
function Dashboard() {
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
// Both fetch simultaneously
}
// Dynamic parallel queries with useQueries
function UserProjects({ userIds }) {
const queries = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
combine: (results) => ({
data: results.map(r => r.data),
pending: results.some(r => r.isPending),
}),
})
}
依存クエリー
// Sequential queries using enabled
function UserPosts({ userId }) {
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId),
enabled: !!userQuery.data, // Only run when user is loaded
})
}
ページネーション付きクエリー
function PaginatedList() {
const [page, setPage] = useState(1)
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: (previousData) => previousData, // Keep showing old data
})
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data.items.map(item => <Item key={item.id} item={item} />)}
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
</div>
)
}
Suspense との統合
import { useSuspenseQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query'
// Component will suspend until data is loaded
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// data is guaranteed to be defined here
return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>
}
// Wrap with Suspense boundary
function App() {
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<TodoList />
</Suspense>
</ErrorBoundary>
)
}
// Multiple suspense queries (fetch in parallel)
function Dashboard() {
const [{ data: users }, { data: projects }] = useSuspenseQueries({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['projects'], queryFn: fetchProjects },
],
})
}
事前取得
const queryClient = useQueryClient()
// Prefetch on hover
function TodoLink({ todoId }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
staleTime: 5000, // Only prefetch if data older than 5s
})
}
return (
<Link to={`/todos/${todoId}`} onMouseEnter={prefetch}>
Todo {todoId}
</Link>
)
}
// Prefetch in route loader (TanStack Router integration)
export const Route = createFileRoute('/todos/$todoId')({
loader: ({ context: { queryClient }, params: { todoId } }) =>
queryClient.ensureQueryData(todoQueryOptions(todoId)),
})
// Prefetch infinite queries
queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
pages: 3, // Prefetch first 3 pages
})
SSR とハイドレーション
サーバーサイドでの事前取得
// Server component or loader
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
async function getServerSideProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Page({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Todos />
</HydrationBoundary>
)
}
ストリーミング SSR(React Server Components)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { makeQueryClient } from './query-client'
export default async function Page() {
const queryClient = makeQueryClient()
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
)
}
QueryClient API
const queryClient = useQueryClient()
// Get cached data
queryClient.getQueryData(['todos'])
// Set cached data
queryClient.setQueryData(['todos'], updatedTodos)
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
// Get query state
queryClient.getQueryState(['todos'])
// Check if fetching
queryClient.isFetching({ queryKey: ['todos'] })
queryClient.isMutating()
// Cancel queries
queryClient.cancelQueries({ queryKey: ['todos'] })
// Invalidate (marks stale, refetches active)
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Refetch (force refetch even if fresh)
queryClient.refetchQueries({ queryKey: ['todos'] })
// Remove from cache
queryClient.removeQueries({ queryKey: ['todos'] })
// Reset to initial state
queryClient.resetQueries({ queryKey: ['todos'] })
// Clear entire cache
queryClient.clear()
// Prefetch
queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos })
// Get/set defaults
queryClient.setQueryDefaults(['todos'], { staleTime: 10000 })
queryClient.getQueryDefaults(['todos'])
queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo })
テスト
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: Infinity, // Prevent garbage collection during tests
},
},
})
return ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
test('fetches todos', async () => {
const { result } = renderHook(() => useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
}), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(expectedTodos)
})
// Mock with setQueryData for component tests
test('renders todos', () => {
const queryClient = new QueryClient()
queryClient.setQueryData(['todos'], mockTodos)
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
)
expect(screen.getByText('Todo 1')).toBeInTheDocument()
})
TypeScript のパターン
クエリー関数の型付け
interface Todo {
id: number
title: string
completed: boolean
}
// Type is inferred from queryFn return type
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos')
return res.json()
},
})
// data: Todo[] | undefined
// With select
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data): string[] => data.map(t => t.title),
})
// data: string[] | undefined
エラーの型付け
// Default error type is Error
const { error } = useQuery<Todo[], AxiosError>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// Or register globally
declare module '@tanstack/react-query' {
interface Register {
defaultError: AxiosError
}
}
クエリーオプションのパターン(推奨)
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'
export const todosOptions = queryOptions({
queryKey: ['todos'] as const,
queryFn: fetchTodos,
staleTime: 5000,
})
export const todoOptions = (id: string) =>
queryOptions({
queryKey: ['todos', id] as const,
queryFn: () => fetchTodo(id),
enabled: !!id,
})
// Full type inference everywhere
const { data } = useQuery(todosOptions)
const { data } = useSuspenseQuery(todoOptions('123'))
await queryClient.ensureQueryData(todosOptions)
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey })
高度なパターン
ウィンドウフォーカス時の再取得
// Disable globally
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false },
},
})
// Custom focus manager
import { focusManager } from '@tanstack/react-query'
// For React Native
focusManager.setEventListener((handleFocus) => {
const subscription = AppState.addEventListener('change', (state) => {
handleFocus(state === 'active')
})
return () => subscription.remove()
})
ネットワークモード
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// 'online' (default): only fetch when online
// 'always': always fetch (useful for local-first)
// 'offlineFirst': try fetch, use cache if offline
networkMode: 'offlineFirst',
})
クエリーのキャンセル
useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
// signal is AbortSignal - automatically cancelled on unmount or key change
const res = await fetch('/api/todos', { signal })
return res.json()
},
})
// Manual cancellation
queryClient.cancelQueries({ queryKey: ['todos'] })
永続化
import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const persister = createSyncStoragePersister({
storage: window.localStorage,
})
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
})
ベストプラクティス
queryOptionsヘルパーを使用すると、型安全で再利用可能なクエリー設定を作成できます- クエリーキーを階層構造にすることで、細かく無効化できます
- 適切な
staleTimeを設定する - 0 はマウント時に常に再取得することを意味します(デフォルト)。変化の少ないデータでは値を大きくします - ページネーション中に前のページのデータを保持するには、
initialDataではなくplaceholderDataを使用する - Suspense 境界を使用する場合は、コンポーネントのコードをより簡潔にできる
useSuspenseQueryを優先する - 依存クエリーには、条件付きのフック呼び出しではなく
enabledを使用する - ミューテーション後は必ず無効化する - 楽観的更新だけに頼らない
- 競合状態を防ぐため、楽観的更新の前に
onMutateでクエリーをキャンセルする - ルートローダーでは、すぐにデータへアクセスできるよう
prefetchQueryではなくensureQueryDataを使用する - タイムアウトの問題を避けるため、テストでは
retry: falseを設定する - クエリー結果を受け渡す必要がある場合は、分割代入しない(リアクティビティが損なわれます)
- コンポーネント内で変換するのではなく、派生データには
selectを使用する - クエリー関数は純粋に保つ - データの取得だけを行い、副作用を起こさないようにします
- アサーション中にキャッシュが削除されないよう、テストでは
gcTime: Infinityを使用する
よくある落とし穴
placeholderDataを使うつもりでinitialDataを使用する(initialDataは「新鮮な」データとして扱われます)- 無限クエリーに
initialPageParamを指定しない(v5 では必須です) - フックを条件付きで呼び出す(React のルールに違反します)
- 楽観的更新の前にクエリーをキャンセルしない(競合状態が発生します)
staleTimeをgcTimeより大きく設定する(データが「新鮮」な間にガベージコレクションされます)- テストを
QueryClientProviderでラップし忘れる - テスト間で同じ
QueryClientインスタンスを使用する(状態が共有されます) - 順序が重要なミューテーションのコールバックで
invalidateQueriesを待機しない
コメント
Markdown 対応