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) },
Query Invalidation
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'] })
Infinite Queries
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> ) }
Parallel Queries
// 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), }), }) }
Dependent Queries
// 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 }) }
Paginated Queries
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 Integration
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 }, ], }) }
Prefetching
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 & Hydration
Server-Side Prefetching
// 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> ) }
Streaming 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 })
Testing
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 Patterns
Typing Query Functions
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
Typing Errors
// 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 } }
Query Options Pattern (Recommended)
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 })
Advanced Patterns
Window Focus Refetching
// 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() })
Network Mode
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', })
Query Cancellation
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'] })
Persistence
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 })
Best Practices
-
Use queryOptions helper for type-safe, reusable query configurations
-
Structure query keys hierarchically for granular invalidation
-
Set appropriate staleTime
-
0 means always refetch on mount (default), increase for less dynamic data
-
Use placeholderData (not initialData ) for keeping previous page data during pagination
-
Prefer useSuspenseQuery when using Suspense boundaries for cleaner component code
-
Use enabled for dependent queries, not conditional hook calls
-
Always invalidate after mutations - don't rely solely on optimistic updates
-
Cancel queries in onMutate before optimistic updates to prevent race conditions
-
Use ensureQueryData in route loaders instead of prefetchQuery for immediate access
-
Set retry: false in tests to avoid timeout issues
-
Don't destructure the query result if you need to pass it around (breaks reactivity)
-
Use select for derived data instead of transforming in the component
-
Keep query functions pure - they should only fetch, not cause side effects
-
Use gcTime: Infinity in tests to prevent cache cleanup during assertions
Common Pitfalls
-
Using initialData when you mean placeholderData (initialData counts as "fresh" data)
-
Not providing initialPageParam for infinite queries (required in v5)
-
Calling hooks conditionally (violates React rules)
-
Not cancelling queries before optimistic updates (race conditions)
-
Setting staleTime higher than gcTime (data gets garbage collected while "fresh")
-
Forgetting to wrap tests with QueryClientProvider
-
Using same QueryClient instance across tests (shared state)
-
Not awaiting invalidateQueries in mutation callbacks when order matters