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.

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>
}