| name | tanstack-query |
| description | Powerful asynchronous state management, server-state utilities, and data fetching for TS/JS, React, Vue, Solid, Svelte & Angular. |
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
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
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:
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos })
useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) })
useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) })
Query Functions
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()
},
})
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,
})
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,
isFetching,
isError,
isSuccess,
isPending,
status,
fetchStatus,
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,
staleTime: 5000,
gcTime: 300000,
refetchInterval: 10000,
refetchIntervalInBackground: false,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
enabled: !!userId,
initialData: () => cachedData,
initialDataUpdatedAt: Date.now() - 10000,
placeholderData: (previousData) => previousData,
placeholderData: initialTodos,
select: (data) => data.filter(todo => !todo.done),
structuralSharing: true,
networkMode: 'online',
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())
},
onMutate: async (variables) => {
return { previousTodos }
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: (data, error, variables, context) => {
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,
mutateAsync,
isPending,
isError,
isSuccess,
isIdle,
data,
error,
reset,
variables,
status,
} = useMutation({ ... })
Optimistic Updates
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
queryClient.setQueryData(['todos', newTodo.id], newTodo)
return { previousTodo }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos', newTodo.id], context.previousTodo)
},
onSettled: () => {
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()
queryClient.invalidateQueries()
queryClient.invalidateQueries({ queryKey: ['todos'] })
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true })
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done',
})
queryClient.refetchQueries({ queryKey: ['todos'] })
queryClient.removeQueries({ queryKey: ['todos', 1] })
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
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPage.prevCursor ?? undefined
},
maxPages: 3,
})
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
function Dashboard() {
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
}
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
function UserPosts({ userId }) {
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId),
enabled: !!userQuery.data,
})
}
Paginated Queries
function PaginatedList() {
const [page, setPage] = useState(1)
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: (previousData) => previousData,
})
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'
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>
}
function App() {
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<TodoList />
</Suspense>
</ErrorBoundary>
)
}
function Dashboard() {
const [{ data: users }, { data: projects }] = useSuspenseQueries({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['projects'], queryFn: fetchProjects },
],
})
}
Prefetching
const queryClient = useQueryClient()
function TodoLink({ todoId }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
staleTime: 5000,
})
}
return (
<Link to={`/todos/${todoId}`} onMouseEnter={prefetch}>
Todo {todoId}
</Link>
)
}
export const Route = createFileRoute('/todos/$todoId')({
loader: ({ context: { queryClient }, params: { todoId } }) =>
queryClient.ensureQueryData(todoQueryOptions(todoId)),
})
queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
pages: 3,
})
SSR & Hydration
Server-Side Prefetching
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()
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
)
}
QueryClient API
const queryClient = useQueryClient()
queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], updatedTodos)
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
queryClient.getQueryState(['todos'])
queryClient.isFetching({ queryKey: ['todos'] })
queryClient.isMutating()
queryClient.cancelQueries({ queryKey: ['todos'] })
queryClient.invalidateQueries({ queryKey: ['todos'] })
queryClient.refetchQueries({ queryKey: ['todos'] })
queryClient.removeQueries({ queryKey: ['todos'] })
queryClient.resetQueries({ queryKey: ['todos'] })
queryClient.clear()
queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos })
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,
gcTime: Infinity,
},
},
})
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)
})
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
}
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos')
return res.json()
},
})
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data): string[] => data.map(t => t.title),
})
Typing Errors
const { error } = useQuery<Todo[], AxiosError>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
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,
})
const { data } = useQuery(todosOptions)
const { data } = useSuspenseQuery(todoOptions('123'))
await queryClient.ensureQueryData(todosOptions)
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey })
Advanced Patterns
Window Focus Refetching
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false },
},
})
import { focusManager } from '@tanstack/react-query'
focusManager.setEventListener((handleFocus) => {
const subscription = AppState.addEventListener('change', (state) => {
handleFocus(state === 'active')
})
return () => subscription.remove()
})
Network Mode
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
networkMode: 'offlineFirst',
})
Query Cancellation
useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const res = await fetch('/api/todos', { signal })
return res.json()
},
})
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,
})
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