Overview
TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box.
Package: @tanstack/react-query
Devtools: @tanstack/react-query-devtools
Current Version: v5
Installation
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools # Optional
Setup
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>
)
}
Core Concepts
Query Keys
Query keys uniquely identify cached data. They must be serializable arrays:
// 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 Functions
// 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 Helper
Create reusable, type-safe query configurations:
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)
Queries (useQuery)
Basic Usage
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} />
}
Query Options
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' },
})
Mutations (useMutation)
Basic Usage
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>
)
}
Mutation State
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({ ... })
Optimistic Updates
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'] })
},
})
Optimistic Updates on Lists
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