⚛TanStack Query (React Query)
useQuery
The v5 API uses a single object argument instead of positional arguments.
import { useQuery } from '@tanstack/react-query'
interface User {
id: string
name: string
}
const { data, error, isPending, isError } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
With options
const { data, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // only run when userId is truthy
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
})
Cancel with signal
queryFn gets an AbortSignal. Pass it to fetch, and React Query aborts the request when the key changes or nothing uses the query any more. More in the AbortController note.
useQuery({
queryKey: ['user', userId],
queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then((res) => res.json()),
})
Conditional queries with skipToken
A type-safe alternative to enabled. TypeScript knows userId is defined inside queryFn, so there’s no ! or extra check.
import { skipToken, useQuery } from '@tanstack/react-query'
useQuery({
queryKey: ['user', userId],
queryFn: userId ? () => fetchUser(userId) : skipToken,
})
refetch() doesn’t work with skipToken. Use enabled if you need it.
As a custom hook
import { useQuery } from '@tanstack/react-query'
interface User {
id: string
name: string
email: string
}
export const useUser = (userId: string) => {
return useQuery({
queryKey: ['user', userId],
queryFn: async (): Promise<User> => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to fetch user')
return response.json()
},
enabled: !!userId,
})
}
Usage in a component
const UserProfile = ({ userId }: { userId: string }) => {
const { data: user, isPending, isError, error } = useUser(userId)
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return <div>Name: {user.name}</div>
}
useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newUser: { name: string }) => {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
})
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
// Usage
mutation.mutate({ name: 'New User' })
Callback signatures changed in v5.89.0
onMutate’s return value became its own argument, onMutateResult, and a real context object moved to the end. Code written before 5.89 that reads the third argument as the onMutate return value still works — the value just moved.
// Before 5.89
onError: (error, variables, context) => {} // context = onMutate's return value
// 5.89+
onMutate: (variables, context) => {}
onError: (error, variables, onMutateResult, context) => {}
onSuccess: (data, variables, onMutateResult, context) => {}
onSettled: (data, error, variables, onMutateResult, context) => {}
context is a MutationFunctionContext: context.client (the QueryClient), context.meta and context.mutationKey. context.client means callbacks can touch the cache without closing over useQueryClient().
mutationFn gets the same context as its second argument:
mutationFn: (variables, { client, meta, mutationKey }) => {}
Optimistic updates
Two approaches. Pick the cheap one first.
Via the UI (variables)
If the pending item only needs to appear in one place, render variables directly — no cache writes, no rollback logic, nothing to undo.
const { mutate, variables, isPending, isError } = useMutation({
mutationFn: (newTodo: string) => addTodo(newTodo),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
{isError && <li onClick={() => mutate(variables)}>{variables} — retry</li>}
</ul>
)
To read those variables from a component that didn’t call mutate, give the mutation a mutationKey and use useMutationState:
const pendingTodos = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables as string,
})
Via the cache (onMutate)
Needed when the update has to show up in several places at once. More code, and you own the rollback.
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo, context) => {
// Cancel in-flight refetches so they don't clobber the optimistic write
await context.client.cancelQueries({ queryKey: ['todos'] })
const previousTodos = context.client.getQueryData(['todos'])
context.client.setQueryData(['todos'], (old: Todo[] = []) => [
...old,
newTodo,
])
// Whatever is returned here arrives as onMutateResult
return { previousTodos }
},
onError: (err, newTodo, onMutateResult, context) => {
context.client.setQueryData(['todos'], onMutateResult?.previousTodos) // undefined if onMutate threw
},
onSettled: (data, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] })
},
})
The cancelQueries call is the part people forget: without it, a refetch that was already in flight can land after the optimistic write and overwrite it with stale server data.
Pagination
Page numbers go in the query key, so each page is cached separately. On its own that means a loading flash on every page change — placeholderData: keepPreviousData keeps the last page on screen while the next one fetches.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
const Projects = () => {
const [page, setPage] = useState(0)
const { data, isPending, isFetching, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
})
if (isPending) return <div>Loading...</div>
return (
<div>
{data.projects.map((project) => (
<p key={project.id}>{project.name}</p>
))}
<button
onClick={() => setPage((old) => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous
</button>
<button
onClick={() => setPage((old) => old + 1)}
// Don't page forward off data we haven't confirmed yet
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
{isFetching && <span>Loading...</span>}
</div>
)
}
isPlaceholderData tells you the data on screen belongs to the previous page — disable “Next” while it’s true, otherwise a fast clicker can skip past the end.
Prefetch the next page to make it feel instant:
useEffect(() => {
if (!isPlaceholderData && data?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['projects', page + 1],
queryFn: () => fetchProjects(page + 1),
})
}
}, [data, isPlaceholderData, page, queryClient])
Infinite queries
useInfiniteQuery stores every page under one query key. initialPageParam and getNextPageParam are both required — getNextPageParam returning undefined or null is what sets hasNextPage to false.
import { useInfiniteQuery } from '@tanstack/react-query'
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
data is { pages, pageParams }, not a flat array — flatten it to render:
const projects = data?.pages.flatMap((page) => page.projects) ?? []
Full signature is getNextPageParam(lastPage, allPages, lastPageParam, allPageParams), so offset-based APIs without a cursor can count what they already have:
getNextPageParam: (lastPage, allPages) =>
lastPage.length === PAGE_SIZE ? allPages.length * PAGE_SIZE : undefined
Add getPreviousPageParam for bi-directional lists (chat scrollback), which gives you fetchPreviousPage and hasPreviousPage. maxPages caps how many pages stay in the cache. With it set, define both param getters, so pages dropped from either end can be fetched again.
Infinite scroll
Trigger fetchNextPage from an IntersectionObserver on a sentinel element at the bottom of the list.
const InfiniteProjects = () => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = sentinelRef.current
if (!el || !hasNextPage) return
const observer = new IntersectionObserver(
([entry]) => {
// isFetchingNextPage guard stops a burst of duplicate fetches
if (entry.isIntersecting && !isFetchingNextPage) fetchNextPage()
},
{ rootMargin: '200px' }, // start fetching before it's actually visible
)
observer.observe(el)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
if (status === 'pending') return <div>Loading...</div>
if (status === 'error') return <div>Something went wrong</div>
return (
<div>
{data.pages.flatMap((page) => page.projects).map((project) => (
<p key={project.id}>{project.name}</p>
))}
<div ref={sentinelRef} />
{isFetchingNextPage && <span>Loading more...</span>}
{!hasNextPage && <span>Nothing left to load</span>}
</div>
)
}
Two things worth knowing:
- Refetching an infinite query refetches every page it holds, sequentially, to keep the cursor chain consistent. A hundred loaded pages is a hundred requests.
maxPagesis the fix. - For long lists, pair this with a virtualiser (TanStack Virtual) — infinite query solves fetching, not the cost of rendering ten thousand DOM nodes.
queryOptions
The queryOptions helper lets you define query configuration in one place and reuse it across useQuery, useSuspenseQuery, useQueries, prefetching, and more. Great for co-locating queryKey and queryFn together.
import { queryOptions, useQuery, useQueries, useSuspenseQuery } from '@tanstack/react-query'
function userOptions(id: string) {
return queryOptions({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 5 * 1000,
})
}
// Usage in components
const { data } = useQuery(userOptions(userId))
const { data: user } = useSuspenseQuery(userOptions(userId))
// Prefetching
queryClient.prefetchQuery(userOptions(userId))
// Setting data directly
queryClient.setQueryData(userOptions(userId).queryKey, newUser)
// Multiple queries
useQueries({
queries: [userOptions('1'), userOptions('2')],
})
Override options at the component level:
const { data } = useQuery({
...userOptions(userId),
select: (data) => data.name, // type inference still works
})
For infinite queries, use infiniteQueryOptions.
mutationOptions
The same idea for mutations — define the config once, reuse it wherever the mutation is triggered or observed.
import { mutationOptions, useMutation, useMutationState } from '@tanstack/react-query'
function updateUserOptions() {
return mutationOptions({
mutationKey: ['updateUser'],
mutationFn: (user: User) => updateUser(user),
onSettled: (data, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['users'] })
},
})
}
const mutation = useMutation(updateUserOptions())
// The shared mutationKey means other components can observe it
const pending = useMutationState({
filters: { mutationKey: updateUserOptions().mutationKey, status: 'pending' },
})
Anything useMutation accepts, mutationOptions accepts. Give it a mutationKey if you want to watch it with useMutationState. Its real value is keeping mutationKey and mutationFn together, so useMutationState filters can’t drift out of sync with the mutation they’re watching.
useQueries
Run multiple queries in parallel.
import { useQueries } from '@tanstack/react-query'
const results = useQueries({
queries: [
{
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
},
{
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
},
],
})
useSuspenseQuery
For use with React Suspense. Data is guaranteed to be defined. It doesn’t accept enabled or placeholderData: a suspense query always runs, and the <Suspense> fallback takes the place of a placeholder.
import { useSuspenseQuery } from '@tanstack/react-query'
const UserProfile = ({ userId }: { userId: string }) => {
// data is guaranteed to be defined
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <div>Name: {user.name}</div>
}
// Wrap with Suspense
const App = () => (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId="123" />
</Suspense>
)
QueryClient 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
retry: 1,
},
},
})
const App = () => (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
Placeholder and initial data
Placeholder data is shown while fetching but never written to the cache. initialData is written to the cache and is treated as real, fresh data.
// A stand-in value while the real thing loads
const { data, isPlaceholderData } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
placeholderData: { id: userId, name: 'Loading...' },
})
// Or keep the previous key's data on screen while the new key fetches
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
placeholderData: keepPreviousData,
})
keepPreviousData is imported from @tanstack/react-query. It replaced the v4 keepPreviousData: true boolean option.
v4 → v5 changes
The ones you’ll trip over in older code:
isLoading→isPending. “No data yet” is nowisPending.isLoadingstill exists but meansisPending && isFetching(the first load is in progress).cacheTime→gcTime.useErrorBoundary→throwOnError.keepPreviousData: true→placeholderData: keepPreviousData. WithplaceholderData,statusissuccesswhile the old data shows. The old option kept the previous query’s status.- Single object argument.
useQuery(key, fn, options)is gone. UseuseQuery({ queryKey, queryFn, ...options }). initialPageParamis required for infinite queries.- Suspense has its own hooks:
useSuspenseQuery,useSuspenseInfiniteQuery,useSuspenseQueries, instead ofsuspense: true.
Related
- TanStack Table — server-side sorting and pagination with
useQuery - TanStack Form — submit with
useMutation, load default values withuseQuery