React TanStack Query: Server State and Caching Guide
TanStack Query (formerly React Query) is the standard solution for managing server state in React applications. Unlike Redux or Zustand, it is built specifically for asynchronous data — fetching, caching, synchronizing, and updating server data with minimal boilerplate. It replaces the typical useEffect + useState pattern for data fetching and adds background refetching, stale-while-revalidate caching, automatic retry, pagination, and optimistic updates out of the box.
Table of Contents
Setup and QueryClient
TanStack Query requires a QueryClient instance and a QueryClientProvider at the root of your application. The QueryClient holds the cache and configuration defaults. Install the devtools for an in-browser cache inspector during development.
npm install @tanstack/react-query @tanstack/react-query-devtools
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // data stays fresh for 5 minutes
gcTime: 1000 * 60 * 10, // cache garbage collected after 10 min
retry: 2, // retry failed requests twice
refetchOnWindowFocus: true, // re-fetch when tab regains focus
},
mutations: {
retry: 0,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
staleTime is the most important setting to tune. Data is considered stale after this period and will be refetched in the background on next access. Set it to Infinity for static reference data that never changes.
useQuery: Fetching Data
useQuery takes a query key and an async function. It returns data, isPending, isError, error, isFetching, and more. The query runs automatically on mount, retries on failure, and refetches in the background when stale.
import { useQuery } from '@tanstack/react-query'
// Basic fetch
function UserProfile({ userId }) {
const { data: user, isPending, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => {
if (!r.ok) throw new Error('Failed to fetch user')
return r.json()
}),
})
if (isPending) return <Skeleton />
if (isError) return <ErrorMessage message={error.message} />
return <div>{user.name}</div>
}
// With axios and typed response
interface Post { id: number; title: string; body: string }
async function fetchPost(id: number): Promise<Post> {
const { data } = await axios.get<Post>(`/api/posts/${id}`)
return data
}
function PostDetail({ postId }: { postId: number }) {
const {
data: post,
isPending,
isError,
isFetching, // true even during background refetch
dataUpdatedAt, // timestamp of last successful fetch
} = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
staleTime: 1000 * 30, // override global default
enabled: postId > 0, // only fetch when postId is valid
select: (data) => ({ // transform/select from data
...data,
titleUpper: data.title.toUpperCase(),
}),
})
return (
<div>
{isFetching && <span className="updating-badge">Updating...</span>}
{post && <article><h1>{post.titleUpper}</h1><p>{post.body}</p></article>}
</div>
)
}
// Parallel queries
function Dashboard() {
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats })
const notifQuery = useQuery({ queryKey: ['notifications'], queryFn: fetchNotifications })
if (usersQuery.isPending || statsQuery.isPending) return <Spinner />
return <DashboardLayout users={usersQuery.data} stats={statsQuery.data} />
}
Query Keys and Dependencies
Query keys are the cache key — any change in the key triggers a new fetch. Always include every variable the query depends on in the key. This makes dependent queries, search, and filter behavior automatic: change the key, and TanStack Query fetches the new data while serving cached data for the old key.
// Key conventions — arrays, nested objects allowed
['posts'] // list of all posts
['post', 42] // single post
['posts', { status: 'published' }] // filtered list
['user', userId, 'posts'] // user's posts
// Dependent query — only runs when userId is available
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', { authorId: user?.id }],
queryFn: () => fetchPosts({ authorId: user.id }),
enabled: !!user, // blocks until user is loaded
})
return <PostList posts={posts} />
}
// Search with debounce
function ProductSearch() {
const [search, setSearch] = useState('')
const [debouncedSearch] = useDebounce(search, 300)
const { data } = useQuery({
queryKey: ['products', { search: debouncedSearch }],
queryFn: () => searchProducts(debouncedSearch),
enabled: debouncedSearch.length > 2,
placeholderData: keepPreviousData, // keep old results visible while fetching new
})
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ProductGrid products={data} />
</div>
)
}
// Query factory — reusable key + fn definitions
const postQueries = {
all: () => ({ queryKey: ['posts'], queryFn: fetchAllPosts }),
detail: (id) => ({ queryKey: ['post', id], queryFn: () => fetchPost(id) }),
byAuthor: (authorId) => ({
queryKey: ['posts', { authorId }],
queryFn: () => fetchPostsByAuthor(authorId),
}),
}
// Usage
const { data } = useQuery(postQueries.detail(postId))
useMutation: Writing Data
useMutation handles POST, PUT, PATCH, DELETE operations. It provides isPending, isSuccess, isError states and callbacks for side effects. After a successful mutation, invalidate related queries to trigger background refetches and keep the cache in sync.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePostForm() {
const queryClient = useQueryClient()
const { mutate, isPending, isError, error } = useMutation({
mutationFn: (newPost) => axios.post('/api/posts', newPost).then(r => r.data),
onSuccess: (createdPost) => {
// Invalidate the posts list so it refetches
queryClient.invalidateQueries({ queryKey: ['posts'] })
// OR directly update the cache without a refetch
queryClient.setQueryData(['post', createdPost.id], createdPost)
toast.success('Post created!')
},
onError: (error) => {
toast.error(`Failed: ${error.message}`)
},
onSettled: () => {
// Runs on both success and error
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
function handleSubmit(e) {
e.preventDefault()
const formData = new FormData(e.target)
mutate({ title: formData.get('title'), body: formData.get('body') })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="body" placeholder="Content" />
{isError && <p className="error">{error.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Publish'}
</button>
</form>
)
}
// mutateAsync for sequential operations
const { mutateAsync: deletePost } = useMutation({
mutationFn: (id) => axios.delete(`/api/posts/${id}`),
})
async function handleBulkDelete(ids) {
try {
await Promise.all(ids.map(id => deletePost(id)))
queryClient.invalidateQueries({ queryKey: ['posts'] })
toast.success(`Deleted ${ids.length} posts`)
} catch {
toast.error('Some deletions failed')
}
}
Cache Configuration and Invalidation
The TanStack Query cache is a key-value store where values are query results with metadata. You can read, write, and invalidate cache entries programmatically. Understanding staleTime vs gcTime is critical: stale data is refetched in the background but still shown; garbage-collected data is completely removed and triggers a loading state on next access.
const queryClient = useQueryClient()
// Invalidate — marks as stale, triggers background refetch on next access
queryClient.invalidateQueries({ queryKey: ['posts'] }) // all post queries
queryClient.invalidateQueries({ queryKey: ['post', 42] }) // specific post
queryClient.invalidateQueries({ queryKey: ['posts'], exact: false }) // prefix match
// Prefetch — populates cache before user navigates
async function prefetchPost(id) {
await queryClient.prefetchQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id),
staleTime: 1000 * 60,
})
}
// On hover — prefetch before click
<Link
to={`/posts/${post.id}`}
onMouseEnter={() => prefetchPost(post.id)}
>
{post.title}
</Link>
// setQueryData — write directly to cache (no network request)
queryClient.setQueryData(['user', userId], (old) => ({
...old,
name: 'Updated Name',
}))
// getQueryData — read from cache synchronously
const cachedUser = queryClient.getQueryData(['user', userId])
// cancelQueries — cancel in-flight requests before optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
Pagination and Infinite Queries
TanStack Query has dedicated support for cursor-based and offset pagination. keepPreviousData (now placeholderData: keepPreviousData) prevents the loading flash when paging. useInfiniteQuery manages load-more patterns with automatic page concatenation.
import { useInfiniteQuery, keepPreviousData } from '@tanstack/react-query'
// Offset pagination
function PaginatedPosts() {
const [page, setPage] = useState(1)
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ['posts', { page }],
queryFn: () => fetchPosts({ page, limit: 10 }),
placeholderData: keepPreviousData, // show old data while fetching next page
})
return (
<div>
{isPending ? <Spinner /> : <PostList posts={data.posts} />}
<div>
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>Prev</button>
<span> Page {page} of {data?.totalPages} </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || page === data?.totalPages}
>Next</button>
</div>
</div>
)
}
// Infinite / load-more
function InfiniteFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
} = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam, limit: 20 }),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
const posts = data?.pages.flatMap(page => page.posts) ?? []
return (
<div>
{isPending && <Spinner />}
{posts.map(post => <PostCard key={post.id} post={post} />)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</div>
)
}
Optimistic Updates
Optimistic updates immediately apply a mutation to the UI before the server confirms it. If the server rejects, TanStack Query rolls back to the previous cache value automatically using the onMutate / onError pattern.
function TodoList() {
const queryClient = useQueryClient()
const toggleTodo = useMutation({
mutationFn: (todo) => axios.patch(`/api/todos/${todo.id}`, { done: !todo.done }),
// 1. Cancel outgoing fetches to prevent race conditions
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot current value for rollback
const previous = queryClient.getQueryData(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === todo.id ? { ...t, done: !t.done } : t)
)
return { previous }
},
// 4. Rollback on error
onError: (err, todo, context) => {
queryClient.setQueryData(['todos'], context.previous)
toast.error('Update failed — rolled back')
},
// 5. Always refetch after settle for consistency
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return <TodoItems onToggle={(todo) => toggleTodo.mutate(todo)} />
}
Advanced Patterns
Custom hooks that wrap useQuery and useMutation are the cleanest way to organise server state. Colocate the query key, fetch function, and any data transformation in one place. Components then import a named hook and never deal with raw query keys or fetch URLs directly.
// Custom hooks encapsulate all query logic
export function useUser(userId) {
return useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => userApi.getById(userId),
enabled: !!userId,
})
}
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }) => userApi.update(id, data),
onSuccess: (user) => {
queryClient.setQueryData(userKeys.detail(user.id), user)
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
},
})
}
// Suspense integration (React 18+)
function SuspensePosts() {
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
// No loading check needed — Suspense boundary handles it
return <PostList posts={data} />
}
function Page() {
return (
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<ErrorFallback />}>
<SuspensePosts />
</ErrorBoundary>
</Suspense>
)
}
// Server-side with Next.js App Router
// app/posts/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ClientPostList /> {/* picks up prefetched data instantly */}
</HydrationBoundary>
)
}