TanStack Query (React Query) — Complete Guide to Server State Management
Advertisement
Introduction
Why This Matters
Every React app fetches data. Without a dedicated server state library, developers end up duplicating loading, error, and stale-data logic across dozens of components. TanStack Query eliminates that duplication and adds background refetching, deduplication, and cache management automatically.
Setup in Next.js App Router
Since Server Components fetch data directly, TanStack Query is primarily useful in Client Components.
// 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 }: { children: React.ReactNode }) {
// Create a new QueryClient per session — not a singleton
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute before background refetch
gcTime: 5 * 60 * 1000, // 5 minutes in cache after unmount
retry: 2,
refetchOnWindowFocus: true
}
}
}))
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}useQuery — Fetching Data
'use client'
import { useQuery } from '@tanstack/react-query'
type Post = { id: number; title: string; body: string }
async function fetchPost(id: number): Promise<Post> {
const res = await fetch(`/api/posts/${id}`)
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
export function PostDetail({ postId }: { postId: number }) {
const { data: post, isLoading, isError, error, isFetching } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
enabled: postId > 0 // Only fetch if postId is valid
})
if (isLoading) return <PostSkeleton />
if (isError) return <p className="text-red-600">Error: {error.message}</p>
return (
<article>
{isFetching && <span className="text-xs text-gray-400">Refreshing...</span>}
<h1 className="text-2xl font-bold">{post.title}</h1>
<p>{post.body}</p>
</article>
)
}useMutation — Creating and Updating Data
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
type CreatePostInput = { title: string; body: string }
export function CreatePostForm() {
const queryClient = useQueryClient()
const createPost = useMutation({
mutationFn: async (input: CreatePostInput) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
})
if (!res.ok) throw new Error('Failed to create post')
return res.json()
},
onSuccess: (newPost) => {
// Invalidate post list so it refetches
queryClient.invalidateQueries({ queryKey: ['posts'] })
// Or update cache directly for instant UI
queryClient.setQueryData(['post', newPost.id], newPost)
},
onError: (error) => {
console.error('Create post failed:', error)
}
})
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
createPost.mutate({
title: data.get('title') as string,
body: data.get('body') as string
})
form.reset()
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input name="title" placeholder="Post title" className="w-full border rounded px-3 py-2" required />
<textarea name="body" placeholder="Post body" rows={4} className="w-full border rounded px-3 py-2" required />
{createPost.isError && (
<p className="text-red-600 text-sm">{createPost.error.message}</p>
)}
<button
type="submit"
disabled={createPost.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}Pagination and Infinite Queries
'use client'
import { useInfiniteQuery } from '@tanstack/react-query'
export function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['posts', 'feed'],
queryFn: ({ pageParam }) =>
fetch(`/api/posts?cursor=${pageParam}&limit=10`).then(r => r.json()),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined
})
const posts = data?.pages.flatMap(page => page.posts) ?? []
return (
<div className="space-y-4">
{posts.map(post => (
<article key={post.id} className="border rounded-lg p-4">
<h2>{post.title}</h2>
</article>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="w-full border rounded py-2 hover:bg-gray-50 disabled:opacity-50"
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}Common Mistakes
- Creating a new
QueryClienton every render — always useuseState(() => new QueryClient())to stabilize the reference - Using React Query for client-only state (UI toggles, form state) — it is designed for server state only
- Overly-broad query keys like
['data']— always include the specific resource ID in the key:['post', postId] - Not calling
queryClient.invalidateQueries()after mutations — stale data remains in cache indefinitely
Best Practices
- Use descriptive, hierarchical query keys:
['users', userId, 'posts', { status: 'published' }] - Set
staleTimebased on how frequently your data changes — higherstaleTimereduces unnecessary network requests - Use
selectoption to transform query data without affecting the cache:select: (data) => data.items - Prefetch data in Server Components using
dehydrateandHydrationBoundaryto eliminate client-side loading states
Key Takeaways
- TanStack Query manages server state: fetching, caching, background syncing, and deduplication automatically
queryKeyis the cache key — always include all variables the query depends on (e.g.,['post', id])staleTimecontrols when background refetches trigger;gcTimecontrols when cached data is garbage collecteduseMutationhandles create/update/delete operations and providesonSuccessandonErrorcallbacksqueryClient.invalidateQueries()marks cached data as stale, triggering a background refetch- Use
useInfiniteQueryfor paginated feeds and lists with cursor-based or offset-based pagination - Never use React Query for client-only state (modal open/close, form values) — use
useStatefor those - React Query DevTools (development only) shows all queries, their states, and cache contents in real time
Advertisement
Related reading
Build an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readNext.js + tRPC Guide — End-to-End Type Safety Best Practices 20265 min readNext.js 15 New Features — Every Breaking Change and Upgrade Explained7 min readNext.js App Router — The Complete Guide for 20267 min readNext.js Server Actions — Replace API Routes with Type-Safe Server Functions 20267 min readReact Server Components in Next.js — Complete Guide 20268 min read