TanStack Query in Production: A Field Guide for 2026

Server state is not client state. A reference guide to the TanStack Query patterns that hold up under real production load — query keys, optimistic updates, invalidation, prefetching, and the anti-patterns that quietly destroy your cache.

engineeringperformancearchitecturefrontendreact
Published
Updated
Version
v1.0
Read
15 min
By
Magnet

Version

v1.0 / current

Most React apps treat server state like client state. They useState it, prop-drill it, lift it, memoize it, and then wonder why three components disagree about whether the user is logged in.

Server state is not client state. It is owned by something you do not control. It can change without your app knowing. It can be stale the moment you receive it. It needs to be cached, invalidated, refetched, and reconciled — not stored. The teams that ship reliable UIs in 2026 understand this in their bones. Everyone else is rebuilding a worse cache, by hand, in every component.

TanStack Query is the answer most production React codebases reach for. It is also one of the easiest libraries to use badly. The defaults are forgiving enough that broken patterns stay invisible until you have ten thousand users and an inexplicable bug report about data that "sometimes won't update."

This is a field guide to the TanStack Query patterns that survive in production. We have shipped enough client apps on top of it to know which ideas hold up at scale and which ones quietly rot the cache. The rules below are not theoretical. Each one came from a specific outage, a specific stale screen, a specific 3 a.m. Slack thread.

The mental model that changes everything

The single most important shift is to stop thinking of a query as "an API call." A query is a subscription to a slice of server state, identified by a key, with a freshness policy attached.

That distinction collapses an entire class of bugs.

If a query is a subscription, then the same useQuery call in twelve components shares one cache entry. Updates propagate. Invalidation is automatic. Concurrent calls deduplicate themselves. You stop writing the glue code you used to write.

If a query is "an API call," you end up reaching for useEffect, you fetch the same data in two places, you store the result in component state, and you spend the next sprint debugging why one view shows the updated record and another does not.

// Server state, owned by the cache
function ProjectHeader({ id }: { id: string }) {
  const { data: project } = useQuery({
    queryKey: ['project', id],
    queryFn: () => api.getProject(id),
  })
  return <h1>{project?.name}</h1>
}

// Same key, same cache entry, no extra request
function ProjectSidebar({ id }: { id: string }) {
  const { data: project } = useQuery({
    queryKey: ['project', id],
    queryFn: () => api.getProject(id),
  })
  return <Status state={project?.status} />
}

Two components. One network request. One cache entry. Both stay in sync forever, without a context, without a store, without a reducer.

That is the whole library. Everything below is a corollary.

Query keys are the contract

The query key is the most important API in the entire library. It is the cache identifier, the dependency array, the invalidation target, and the contract between the caller and the cache — all at once.

Most production bugs we see come down to query keys that are wrong, inconsistent, or insufficiently specific.

Keys are deterministic, structural, and serializable. TanStack Query hashes them deterministically and compares them structurally. ['user', 1] is the same key as ['user', 1] no matter where in the codebase it appears. ['user', { id: 1 }] is the same as ['user', { id: 1 }] — and importantly, the same as ['user', { id: 1, foo: undefined }]. Order inside arrays matters; order inside object literals does not.

Keys describe the data, not the endpoint. The temptation is to write ['/api/users/1']. Do not. The key should describe the entity, with its parameters as structured pieces.

queryKey: ['user', userId]
queryKey: ['users', { workspaceId, status: 'active' }]
queryKey: ['user', userId, 'permissions']

This pays off the moment you need to invalidate. "Refetch every user in this workspace" becomes one call. "Refetch every permissions query regardless of user" becomes one call. Keys structured around URLs can't do that.

Every variable input is part of the key. If your queryFn reads a value, that value belongs in the key. If it doesn't, the cache will happily serve stale data for the wrong input — and the bug will be infuriating to reproduce.

// Wrong: filters are not in the key
useQuery({
  queryKey: ['issues'],
  queryFn: () => api.getIssues({ status, assignee }),
})

// Right: every input the query depends on is in the key
useQuery({
  queryKey: ['issues', { status, assignee }],
  queryFn: () => api.getIssues({ status, assignee }),
})

The rule: if the function reads it, the key contains it. No exceptions.

The two timers everyone gets wrong

TanStack Query has two timers. They control different things and they confuse almost everyone.

staleTime is the freshness contract. It says "for the next N milliseconds, I am willing to show this data without checking the server again." With staleTime: 0 (the default), every component mount triggers a refetch. That is the right default for the library, but it is rarely the right default for your app.

The honest default for most production codebases is 30 seconds to 5 minutes, scaled to how often the underlying data changes. Reference data (configs, taxonomies, schemas) can be measured in hours. User-specific data that mutates frequently stays at seconds. Real-time data has its own freshness mechanism, usually a subscription, and staleTime: Infinity because the cache is updated by the socket, not by refetches.

gcTime is when the cache entry gets evicted entirely after nothing is observing it. Bump it up for queries the user navigates back to constantly — a list view that the user enters from every detail page should not refetch from scratch every time. Bump it down only when memory pressure is real.

new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,        // 1 minute fresh
      gcTime: 5 * 60_000,       // 5 minutes in memory after unobserved
      refetchOnWindowFocus: true,
      retry: 2,
    },
  },
})

The right defaults at the client level let you stop thinking about caching in 95% of components. The remaining 5% override locally and the override is meaningful.

Mutations: optimistic by default

Mutations are where TanStack Query separates apps that feel snappy from apps that feel networked.

The naive pattern is to call mutate, wait for onSuccess, then call invalidateQueries to refetch. That works. It is also slow. You pay the round trip on every action, the user watches a spinner, and the cache rebuilds from scratch even when you already know what changed.

Production code mutates optimistically. The cache updates the moment the user clicks. The server confirms in the background. If the server says no, the cache rolls back. The user is told.

const queryClient = useQueryClient()

const updateIssue = useMutation({
  mutationFn: (update: IssueUpdate) => api.updateIssue(update),

  onMutate: async (update) => {
    await queryClient.cancelQueries({ queryKey: ['issue', update.id] })

    const previous = queryClient.getQueryData<Issue>(['issue', update.id])

    queryClient.setQueryData<Issue>(['issue', update.id], (old) =>
      old ? { ...old, ...update } : old,
    )

    return { previous }
  },

  onError: (_err, update, context) => {
    if (context?.previous) {
      queryClient.setQueryData(['issue', update.id], context.previous)
    }
  },

  onSettled: (_data, _err, update) => {
    queryClient.invalidateQueries({ queryKey: ['issue', update.id] })
  },
})

This pattern has four moving parts, all of which matter.

The onSettled invalidation is the part most teams skip. It is the part that catches the case where the server transforms your input — derived fields, computed counts, validation cleanup — and your optimistic guess was slightly off. Without it, your cache slowly drifts from the server.

For mutations that affect multiple cache entries — creating an item in a list, deleting a record that appears in three views — invalidate them all in onSettled, scoped by key prefix:

onSettled: () => {
  queryClient.invalidateQueries({ queryKey: ['issues'] })       // every list
  queryClient.invalidateQueries({ queryKey: ['workspace'] })    // counts
}

Invalidation by prefix is the second great feature of structured query keys. ['issues'] matches ['issues'], ['issues', { status: 'open' }], ['issues', { status: 'closed', assignee: 'me' }], and every other key in that namespace. One call, every list refetches.

Defeating the waterfall

Slow apps are made of waterfalls. Component renders. Query starts. Data returns. Child component renders. Child query starts. Data returns. Grandchild renders. By the time the page is interactive, the user has watched three skeletons in sequence.

TanStack Query gives you four tools to break the chain. All four belong in your default toolkit.

Parallel queries. Use useQueries when a component needs multiple independent pieces of data. Each query runs in parallel; the component re-renders as each resolves. Never write two useQuery calls in the same component and expect them to be parallel — they will be, but you cannot pass a single loading state to your skeleton without composing it manually, and the next engineer to touch the code will refactor it into a sequential call without realizing.

const results = useQueries({
  queries: [
    { queryKey: ['project', id], queryFn: () => api.getProject(id) },
    { queryKey: ['project', id, 'members'], queryFn: () => api.getMembers(id) },
    { queryKey: ['project', id, 'activity'], queryFn: () => api.getActivity(id) },
  ],
})

const isLoading = results.some((r) => r.isLoading)

Prefetching on intent. The cheapest network request is the one that already happened. When the user hovers a link, prefetch the next route's data. By the time they click, the cache is warm.

function ProjectLink({ id, children }: { id: string; children: ReactNode }) {
  const queryClient = useQueryClient()
  return (
    <Link
      href={`/projects/${id}`}
      onMouseEnter={() =>
        queryClient.prefetchQuery({
          queryKey: ['project', id],
          queryFn: () => api.getProject(id),
          staleTime: 30_000,
        })
      }
    >
      {children}
    </Link>
  )
}

The staleTime on prefetchQuery matters. Without it, the prefetch and the actual mount issue two separate requests because the prefetched data is immediately stale.

Server-side prefetching with hydration. In Next.js App Router or any SSR framework, prefetch on the server, dehydrate into the HTML, hydrate on the client. The user sees real data on first paint. No skeleton. No flash.

// app/projects/[id]/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

export default async function ProjectPage({ params }: { params: { id: string } }) {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['project', params.id],
    queryFn: () => api.getProject(params.id),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProjectView id={params.id} />
    </HydrationBoundary>
  )
}

The client-side useQuery with the same key reads from the dehydrated cache. No flash, no second request, the same code on both sides of the SSR boundary.

Suspense for cleaner loading composition. useSuspenseQuery throws a promise while loading. Wrap a section in <Suspense> and the parent decides what loading state to show. This is how you compose loading states without each component drawing its own skeleton.

function ProjectView({ id }: { id: string }) {
  const { data: project } = useSuspenseQuery({
    queryKey: ['project', id],
    queryFn: () => api.getProject(id),
  })
  return <Header project={project} />
}

Suspense changes the rules: errors throw to the nearest error boundary, loading states bubble to the nearest Suspense boundary. The result is fewer if (isLoading) branches and far cleaner JSX.

Cache invalidation, deliberately

There are only two hard problems in computer science. Cache invalidation is both of them.

TanStack Query gives you three knobs for invalidation, each appropriate for a different situation.

invalidateQueries marks queries as stale and refetches the ones that are currently observed. This is the default and the one you reach for 80% of the time. It is also the one that does the most network work, because every list, count, and detail in the affected namespace will refetch.

setQueryData writes a specific value into the cache directly. Use this when you already know the new state — typically the response of a mutation. It avoids the refetch entirely.

const updateIssue = useMutation({
  mutationFn: api.updateIssue,
  onSuccess: (updated) => {
    queryClient.setQueryData(['issue', updated.id], updated)
  },
})

removeQueries evicts entries from the cache entirely. Use this for logout, workspace switches, or anything where the previous data is now inaccessible and stale is worse than empty.

function logout() {
  queryClient.removeQueries()
  router.push('/login')
}

The mistake most teams make is reaching for invalidateQueries on every mutation, even when they know the new value. The result is a network request after every action. The user clicks save, sees the optimistic update, and then watches a brief loading state as the invalidation refetches data the client already had.

A better default for write paths: set the response, then invalidate the lists.

onSuccess: (updated) => {
  // Write what we know
  queryClient.setQueryData(['issue', updated.id], updated)
  // Refetch derived views (lists, counts, search)
  queryClient.invalidateQueries({ queryKey: ['issues'] })
},

Detail views update instantly with no extra request. List views refetch because their results may have shifted. This is the pattern that makes mutation-heavy UIs feel native.

Query factories: taming a real codebase

The patterns above work in any size codebase. Inside a real product, you will repeat them hundreds of times. The codebase will fragment unless you centralize the contract.

The pattern that scales is the query factory: a single source of truth for every query in a domain.

// queries/projects.ts
import { queryOptions } from '@tanstack/react-query'

export const projectQueries = {
  all: () => queryOptions({
    queryKey: ['projects'],
    queryFn: () => api.listProjects(),
  }),

  detail: (id: string) => queryOptions({
    queryKey: ['project', id],
    queryFn: () => api.getProject(id),
    staleTime: 60_000,
  }),

  members: (id: string) => queryOptions({
    queryKey: ['project', id, 'members'],
    queryFn: () => api.getMembers(id),
  }),

  activity: (id: string, params: ActivityParams) => queryOptions({
    queryKey: ['project', id, 'activity', params],
    queryFn: () => api.getActivity(id, params),
    staleTime: 10_000,
  }),
}

queryOptions is the V5 helper that gives you a typed object you can pass to useQuery, useSuspenseQuery, prefetchQuery, fetchQuery, or useQueries interchangeably. The same definition works on the server and the client. Refactoring a key updates every call site through TypeScript.

// In a component
const { data } = useQuery(projectQueries.detail(id))

// On the server
await queryClient.prefetchQuery(projectQueries.detail(id))

// In a mutation's onSettled
queryClient.invalidateQueries({ queryKey: projectQueries.detail(id).queryKey })

The factory pattern is not optional past a certain codebase size. Without it, query keys drift, staleTime decisions live in three different opinions, and invalidations miss queries because nobody can find them all. With it, a domain's data layer is one file, fully typed, and self-documenting.

Anti-patterns we have stopped tolerating

Most TanStack Query bugs come from a small set of recurring mistakes. We have a short list of patterns that get flagged in code review on sight.

The two-line version of all of these: stop fighting the cache. The cache is the library. If you find yourself manually controlling when fetches happen, manually storing results, or maintaining a parallel piece of state next to a query, you are working against the grain.

Error handling that holds up

Production error handling in TanStack Query is unglamorous and matters more than almost anything else.

Retry policies should be deliberate. The default is three retries with exponential backoff. That is correct for transient network errors and incorrect for 4xx responses. A 404 should not be retried three times. A 401 should not be retried at all — it should redirect to login.

new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
          return false
        }
        return failureCount < 3
      },
    },
  },
})

Error boundaries handle render-blocking errors. useQuery returns errors for everything else. Suspense queries throw to the nearest error boundary. Non-suspense queries expose error and isError. A production UI handles both: an error boundary at the page level for catastrophic failures, in-component error rendering for the case where the rest of the screen is still useful.

Mutations need their own error handling. A failed mutation is not a render failure; it is a user-facing event. Use onError to show a toast, log to your error tracker, and roll back optimistic updates. Never let a mutation error fail silently.

const mutation = useMutation({
  mutationFn: api.updateIssue,
  onError: (err, _vars, context) => {
    rollback(context?.previous)
    toast.error('Could not save changes. Please try again.')
    logger.error('mutation_failed', { err })
  },
})

The patterns that matter, in one list

If you take only the operational rules from this guide and apply nothing else, your TanStack Query code will be in the top quartile of every codebase we have audited.

The Magnet thesis

The reason TanStack Query is everywhere is not the API. It is the mental model. Server state is its own category of state, with its own rules, and treating it that way is the difference between a UI that feels native and a UI that feels networked.

The teams that build software that feels instant in 2026 have all internalized the same shift: the cache is the source of truth for the UI, the server is the source of truth for correctness, and the two reconcile in the background. TanStack Query is the most direct expression of that idea in the React ecosystem. We have written about the broader architecture in How to Build a Blazing Fast App in 2026; this piece is the implementation layer for one specific piece of it.

The patterns above are not exotic. They are not version-of-the-week. They are the ones that survive after the codebase grows past a single engineer, after the user count grows past a single workspace, and after the product grows past a single happy path. The library is mature. The patterns are well-understood. The reason most apps still feel slow on every mutation is that teams keep writing TanStack Query as if it were a fetching helper, when it is actually a cache.

Treat it as a cache. Centralize the keys. Mutate optimistically. Invalidate by prefix. Tune the timers. Prefetch on the server. Stop reaching for useEffect.

The rest is just shipping.

Further reading

All postsv1.0 / tanstack-query-in-production