TanStack Query (React Query) — Complete Guide to Server State Management

Sanjeev SharmaSanjeev Sharma
5 min read

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 QueryClient on every render — always use useState(() => 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 staleTime based on how frequently your data changes — higher staleTime reduces unnecessary network requests
  • Use select option to transform query data without affecting the cache: select: (data) => data.items
  • Prefetch data in Server Components using dehydrate and HydrationBoundary to eliminate client-side loading states

Key Takeaways

  • TanStack Query manages server state: fetching, caching, background syncing, and deduplication automatically
  • queryKey is the cache key — always include all variables the query depends on (e.g., ['post', id])
  • staleTime controls when background refetches trigger; gcTime controls when cached data is garbage collected
  • useMutation handles create/update/delete operations and provides onSuccess and onError callbacks
  • queryClient.invalidateQueries() marks cached data as stale, triggering a background refetch
  • Use useInfiniteQuery for 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 useState for those
  • React Query DevTools (development only) shows all queries, their states, and cache contents in real time

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading