React TanStack Query: Server State and Caching Guide (2026)
TanStack Query (formerly React Query) is the de-facto standard for managing server state in React applications. It handles fetching, caching, synchronization and background updates — removing the need for useEffect/useState boilerplate for data fetching. Version 5 brings a streamlined API, first-class TypeScript support and deep Suspense integration.
Table of Contents
Setup
npm install @tanstack/react-query @tanstack/react-query-devtools
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute — data stays fresh
gcTime: 5 * 60 * 1000, // 5 minutes — cache kept in memory
retry: 1,
refetchOnWindowFocus: false,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
useQuery Essentials
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
const {
data: user,
isLoading,
isError,
error,
isFetching, // true during background refetch
isStale, // true when data is older than staleTime
refetch,
} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only run when userId exists
staleTime: 5 * 60 * 1000, // Override default — 5 min fresh
select: (data) => data.user, // Transform data before returning
})
if (isLoading) return <Skeleton />
if (isError) return <p>Error: {error.message}</p>
return (
<div>
{isFetching && <span>Refreshing...</span>}
<h1>{user.name}</h1>
</div>
)
}
Query Keys
Query keys identify and cache your queries. Treat them like dependency arrays:
// Static key — same data for all users
useQuery({ queryKey: ['settings'], queryFn: fetchSettings })
// Dynamic key — separate cache entry per userId
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) })
// Nested objects — filter params
useQuery({
queryKey: ['products', { category, page, sort }],
queryFn: () => fetchProducts({ category, page, sort }),
})
// Invalidate all product queries
queryClient.invalidateQueries({ queryKey: ['products'] })
// Invalidate only filtered queries
queryClient.invalidateQueries({ queryKey: ['products', { category: 'shoes' }] })
Key convention: Use arrays with a resource name first, then identifiers, then filters. This lets you invalidate at different levels of specificity.
useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePostForm() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newPost) => fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(r => r.json()),
onSuccess: (data) => {
// Invalidate and refetch posts list
queryClient.invalidateQueries({ queryKey: ['posts'] })
toast.success('Post created!')
},
onError: (error) => {
toast.error(error.message)
},
})
function handleSubmit(e) {
e.preventDefault()
mutation.mutate({ title: e.target.title.value, body: e.target.body.value })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<textarea name="body" />
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Create Post'}
</button>
</form>
)
}
Cache Invalidation
const queryClient = useQueryClient()
// Invalidate — marks stale, refetches if observed
queryClient.invalidateQueries({ queryKey: ['posts'] })
// Refetch immediately — even if not observed
queryClient.refetchQueries({ queryKey: ['posts'] })
// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['posts'] })
// Set cache manually (useful after create/update)
queryClient.setQueryData(['post', newPost.id], newPost)
// Update existing cache entry
queryClient.setQueryData(['posts'], (old) =>
old ? [...old, newPost] : [newPost]
)
Optimistic Updates
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel in-flight refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot current value for rollback
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update UI
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
)
return { previousTodos }
},
onError: (err, variables, context) => {
// Rollback to snapshot on error
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Infinite Queries
import { useInfiniteQuery } from '@tanstack/react-query'
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ page: pageParam, limit: 10 }),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
lastPage.hasMore ? pages.length + 1 : undefined,
})
return (
<div>
{data?.pages.flatMap(page => page.posts).map(post => (
<PostCard key={post.id} post={post} />
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more posts'}
</button>
</div>
)
}
Prefetching
// Prefetch on hover — data ready before user clicks
function PostLink({ postId, children }) {
const queryClient = useQueryClient()
return (
<a
href={`/posts/${postId}`}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
staleTime: 10 * 1000,
})
}}
>
{children}
</a>
)
}
// Prefetch in Next.js Server Component
async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
Suspense Mode
// useSuspenseQuery — no isLoading/isError needed in the component
import { useSuspenseQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// data is always defined here — Suspense handles loading, ErrorBoundary handles errors
return <div>{user.name}</div>
}
// Parent wraps with Suspense + ErrorBoundary
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<Skeleton />}>
<UserProfile userId={id} />
</Suspense>
</ErrorBoundary>
Next.js Integration
// Dehydrate on server, rehydrate on client
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
// app/products/page.tsx (Server Component)
export default async function ProductsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
)
}
// ProductList.tsx (Client Component)
'use client'
function ProductList() {
// Cache already populated from server — no loading state on first render
const { data } = useQuery({ queryKey: ['products'], queryFn: fetchProducts })
return <ul>{data?.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}