Gerson

Gerson

Passionate developer specializing in web development, cloud architecture, and system design.

TypeScriptReactNext.jsPythonFastAPISQLNode.jsAWS

Mastering TanStack Query v5: The Complete Guide to Server State in React

A practical deep-dive into TanStack Query v5 covering queries, mutations, optimistic updates, infinite scrolling, and Next.js SSR integration. Includes real-world TypeScript patterns and common mistakes to avoid.

Gersonhttps://tanstack.com/query/v5
Code on a monitor representing data fetching architecture

If you've ever written a React component that fetches data, you've probably written some version of this: a useEffect that calls an API, a useState for the response, another useState for loading, another for errors, and maybe some caching logic duct-taped on top. It works, but it's tedious, error-prone, and you end up rewriting the same plumbing in every component.

TanStack Query (previously known as React Query) exists to eliminate that entire category of boilerplate. It gives you a declarative, hook-based API for fetching, caching, and synchronizing server data — and it handles the hard parts (cache invalidation, background refetching, race conditions, retries) so you don't have to.

This guide covers TanStack Query v5 end-to-end, with TypeScript examples you can drop into a real project.

The Problem TanStack Query Solves

State management tools like Redux and Zustand work well for things the user controls directly — which modal is open, what's in a shopping cart, dark mode preferences. This is client state: it lives entirely in the browser and your code is the only thing that changes it.

Server state is a different animal. The data lives on a remote server. Other users can modify it while you're looking at it. You need network requests to read or write it, and those requests can fail, be slow, or return stale results. Most client-state libraries weren't designed with these challenges in mind, which is why fetching data with Redux always felt like so much ceremony.

TanStack Query treats server state as its own concern and provides purpose-built tools for it: automatic caching with configurable staleness, background refetching when users return to a tab, retry logic with exponential backoff, deduplication of identical requests, and much more — all without you writing a single line of caching code.

Notable Changes in v5

If you're coming from v4, here are the changes that'll affect your code most:

  • Every hook now takes a single object argument — the multiple-argument overloads are gone, which makes the TypeScript experience much cleaner
  • The loading status is now called pending, and isLoading is now isPending
  • cacheTime was renamed to gcTime (garbage collection time) to better describe what it actually controls
  • The onSuccess, onError, and onSettled callbacks were removed from useQuery — they caused subtle bugs when background refetches triggered them unexpectedly. Mutation hooks still have them.
  • useSuspenseQuery is now a first-class hook (no more experimental flag) and guarantees that data is defined — no undefined checks needed
  • The new queryOptions helper lets you define a query configuration once and reuse it across hooks, prefetching, and cache reads with full type inference
  • Infinite queries gained a maxPages option so you can cap how many pages are stored in memory
  • The bundle is roughly 20% smaller thanks to dropping IE11 support and using modern JavaScript features

Setting Up

Install the library and the (optional but highly recommended) devtools:

Terminal

npm install @tanstack/react-query @tanstack/react-query-devtools

Wrap your app in a QueryClientProvider. The QueryClient is where you configure defaults for all queries:

src/main.tsx

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,          // data stays "fresh" for 1 minute
      gcTime: 5 * 60_000,         // inactive cache is garbage-collected after 5 min
      retry: 3,                   // failed requests retry 3 times
      refetchOnWindowFocus: true, // refetch stale data when the user returns to the tab
    },
  },
})

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

Tip: The devtools panel lets you inspect every cached query, see its status and data, and manually trigger refetches or removals. It's one of the best debugging tools in the React ecosystem — don't skip it.

Fetching Data with useQuery

The useQuery hook is the bread and butter of TanStack Query. You give it a query key (an array that uniquely identifies the data) and a query function (an async function that fetches it):

components/TodoList.tsx

import { useQuery } from '@tanstack/react-query'

interface Todo {
  id: number
  title: string
  completed: boolean
}

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('https://api.example.com/todos')
  if (!res.ok) throw new Error('Request failed')
  return res.json()
}

function TodoList() {
  const { data, error, isPending, isFetching, isError } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 5 * 60_000,
    select: (todos) => todos.filter(t => !t.completed),
  })

  if (isPending) return <p>Loading...</p>
  if (isError) return <p>Something went wrong: {error.message}</p>

  return (
    <>
      <ul>
        {data.map(todo => <li key={todo.id}>{todo.title}</li>)}
      </ul>
      {isFetching && <small>Refreshing...</small>}
    </>
  )
}

A few things to notice: isPending is true only when there's no cached data yet and we're still fetching. isFetching is true during any fetch, including silent background refetches. And the select option lets you transform data before it reaches your component — TanStack Query memoizes the result, so your component only re-renders when the filtered output actually changes.

Sharing Query Definitions with queryOptions

In a real app, you'll want to reuse the same query configuration across multiple components, in prefetching calls, and when reading from the cache directly. The queryOptions helper makes this type-safe and DRY:

queries/todos.ts

import { queryOptions } from '@tanstack/react-query'

export const todoListOptions = () =>
  queryOptions({
    queryKey: ['todos'] as const,
    queryFn: fetchTodos,
    staleTime: 5 * 60_000,
  })

export const todoDetailOptions = (id: number) =>
  queryOptions({
    queryKey: ['todos', id] as const,
    queryFn: () => fetchTodoById(id),
  })

// Now usable everywhere with full type inference:
const { data } = useQuery(todoListOptions())               // in a component
await queryClient.prefetchQuery(todoListOptions())          // in a loader
const cached = queryClient.getQueryData(todoListOptions().queryKey) // cache read

Writing Data with Mutations

Queries read data; mutations write it. The useMutation hook handles POST, PUT, PATCH, and DELETE operations. The most common pattern is to invalidate related queries after a successful mutation so the UI stays in sync:

hooks/useCreateTodo.ts

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useCreateTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (newTodo: { title: string }) => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newTodo),
      })
      if (!res.ok) throw new Error('Failed to create todo')
      return res.json() as Promise<Todo>
    },
    onSuccess: () => {
      // Tell TanStack Query that the todo list is outdated
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
}

// In a component:
function AddTodoForm() {
  const { mutate, isPending } = useCreateTodo()

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const title = new FormData(e.currentTarget).get('title') as string
      mutate({ title })
    }}>
      <input name="title" required />
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Add'}
      </button>
    </form>
  )
}

Making the UI Feel Instant with Optimistic Updates

Invalidation works, but there's a perceptible delay while the server processes the request and the refetch completes. For interactions where speed matters — adding an item to a list, toggling a checkbox, liking a post — you can update the cache immediately and roll back if the server rejects it:

hooks/useCreateTodoOptimistic.ts

function useCreateTodoOptimistic() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: createTodo,
    onMutate: async (newTodo) => {
      // Stop any in-flight refetches from overwriting our optimistic data
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // Save the current state in case we need to undo
      const snapshot = queryClient.getQueryData<Todo[]>(['todos'])

      // Write the optimistic value into the cache immediately
      queryClient.setQueryData<Todo[]>(['todos'], (prev) => [
        ...(prev ?? []),
        { id: Math.random(), ...newTodo, completed: false },
      ])

      return { snapshot }
    },
    onError: (_err, _vars, context) => {
      // Something went wrong — restore the previous data
      if (context?.snapshot) {
        queryClient.setQueryData(['todos'], context.snapshot)
      }
    },
    onSettled: () => {
      // Whether it succeeded or failed, refetch to get the real server state
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
}

The pattern has four steps: cancel competing refetches, snapshot the current cache, write optimistic data, and restore the snapshot on failure. The onSettled callback always fires a real refetch at the end, so even if the optimistic ID doesn't match the server's ID, the cache converges to the correct state.

Query Invalidation Strategies

Cache invalidation is often called one of the hardest problems in computer science. TanStack Query makes it manageable with a flexible matching system:

Invalidation patterns

const queryClient = useQueryClient()

// Prefix match (default) — invalidates ['todos'], ['todos', 1], ['todos', { status }]
queryClient.invalidateQueries({ queryKey: ['todos'] })

// Exact match — only invalidates the ['todos'] key, not ['todos', 1]
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })

// Custom predicate for complex logic
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'todos' &&
    (query.queryKey[1] as number) > 100,
})

// Mark stale without triggering a refetch
queryClient.invalidateQueries({
  queryKey: ['todos'],
  refetchType: 'none',
})

A practical tip: structure your query keys like a file path. Put the broadest category first and get more specific: ['todos', 'list', { filters }] and ['todos', 'detail', todoId]. That way you can invalidate everything todo-related with ['todos'], or just the list with ['todos', 'list'].

Infinite Scrolling and Load-More Pagination

Cursor-based pagination — the "load more" button or infinite scroll pattern — has dedicated support through useInfiniteQuery:

components/ProjectFeed.tsx

import { useInfiniteQuery } from '@tanstack/react-query'

interface Page {
  items: Project[]
  nextCursor: string | null
}

function ProjectFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: async ({ pageParam }): Promise<Page> => {
      const res = await fetch(`/api/projects?cursor=${pageParam}&limit=20`)
      return res.json()
    },
    initialPageParam: '',
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    maxPages: 5, // keep at most 5 pages in memory
  })

  return (
    <>
      {data?.pages.flatMap(page =>
        page.items.map(project => (
          <ProjectCard key={project.id} project={project} />
        ))
      )}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load more'}
        </button>
      )}
    </>
  )
}

The maxPages option (new in v5) is worth knowing about — in a long infinite scroll, you might accumulate hundreds of pages in memory. Setting a cap means old pages are dropped and re-fetched if the user scrolls back up.

Server-Side Rendering with Next.js

TanStack Query works with Next.js App Router through a prefetch-and-hydrate pattern. You fetch data in a Server Component, dehydrate the cache into serializable state, and pass it to the client where it's rehydrated — the client component gets the data instantly without a loading state.

app/get-query-client.ts

import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

// React's cache() ensures one QueryClient per server request
// On the client, it's created once and reused
const getQueryClient = cache(
  () => new QueryClient({
    defaultOptions: {
      queries: { staleTime: 60_000 },
    },
  })
)

export default getQueryClient

app/posts/page.tsx (Server Component)

import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import getQueryClient from '@/app/get-query-client'
import PostList from './post-list'

export default async function PostsPage() {
  const queryClient = getQueryClient()

  // This runs on the server — the data is ready before any HTML is sent
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/posts')
      return res.json()
    },
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostList />
    </HydrationBoundary>
  )
}

app/posts/post-list.tsx (Client Component)

'use client'

import { useSuspenseQuery } from '@tanstack/react-query'

export default function PostList() {
  // The cache already has this data from the server prefetch.
  // No loading spinner, no layout shift — it's just there.
  const { data: posts } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/posts')
      return res.json()
    },
  })

  return (
    <ul>
      {posts.map((post: { id: number; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

One critical detail: set staleTime to something greater than zero on the server-side QueryClient. If you leave it at the default (zero), the client will consider the prefetched data stale immediately and fire a redundant refetch the moment it mounts — defeating the purpose of SSR.

How the Cache Actually Works

TanStack Query uses a strategy called stale-while-revalidate. Two timers control it:

  • staleTime — how long data is considered fresh after fetching. While it's fresh, TanStack Query returns the cached value and skips the network entirely. After this window, the data is "stale" and eligible for a background refetch.
  • gcTime — how long unused data sticks around after the last component that was watching it unmounts. Once this timer runs out, the cache entry is deleted entirely.

The user experience this creates: you navigate to a page, the data loads. You navigate away and come back — the cached data appears instantly (no spinner), while a background request quietly checks for updates. If the data changed on the server, the UI updates seamlessly. If it didn't, nothing visible happens.

Keep gcTime greater than or equal to staleTime. If you set gcTime shorter, the cache entry might get deleted before the staleness window is up, and you'll get a full loading state instead of the instant stale-data-then-update experience.

Comparison with Alternatives

The three libraries you'll most often see compared:

SWR is Vercel's data-fetching library. It covers the basics well with a smaller API surface, but lacks built-in devtools, garbage collection, infinite query primitives, and the kind of granular cache control TanStack Query provides. It's React-only.

RTK Query makes sense if your project already depends on Redux. It has a unique "API slice" pattern where you define all endpoints upfront and mutations automatically invalidate related queries by tag. The trade-off is that you're buying into the Redux ecosystem.

Plain useEffect + fetch is what most tutorials teach, and it's fine for a demo, but it doesn't handle caching, deduplication, retries, race conditions, background refetching, or any of the other concerns that matter in production. You'd end up rebuilding a worse version of TanStack Query.

TanStack Query is the most full-featured option, works across React, Vue, Solid, Svelte, and Angular, and has official devtools that are genuinely useful. For most React applications that talk to a server, it's the strongest default choice.

Mistakes That Bite People

  1. Copying query data into Redux or Context. TanStack Query already is your server-state cache. Duplicating the data into another store creates two sources of truth that inevitably fall out of sync.
  2. Leaving staleTime at zero without understanding the implications. The default of zero means "always stale," which triggers refetches on every mount, tab focus, and reconnect. That's deliberate, but it surprises people. Set a sensible default at the QueryClient level.
  3. Accidentally creating request waterfalls. If a parent component fetches user data and a child component fetches the user's posts, those requests run sequentially. Prefetch both in a route loader, or use useQueries to run them in parallel.
  4. Forgetting to cancel queries in optimistic updates. If you skip the cancelQueries call in onMutate, a background refetch that was already in flight can land and overwrite your optimistic data with the old server state.
  5. SSR without staleTime. As mentioned above, the client will immediately re-request data that the server just fetched. A staleTime of even 10 seconds prevents this.

Resources