Skip to content

Web Development · Data Fetching

TanStack Query in 2026: Server State Management That Gets Out of Your Way

Client state is easy. Server state — data that lives on a backend, changes over time, and needs to stay in sync — is where most React apps make a mess. TanStack Query handles it without ceremony.

Anurag Verma

Anurag Verma

8 min read

TanStack Query in 2026: Server State Management That Gets Out of Your Way

Sponsored

Share

Most React apps manage state in two very different ways and blur the line between them. There’s client state — UI flags, form inputs, modal open/close — which lives purely in the browser. And there’s server state — your user’s profile, their order history, the list of products — which lives on a backend, can change independently of what the user is doing, and needs to stay accurate.

The mistake most codebases make is treating server state like client state. They put API responses into Redux or Zustand, manually track loading and error flags, and write useEffect calls that fetch on mount. It works, until it doesn’t. You get stale data, missed loading states, inconsistent error handling, and duplicate requests.

TanStack Query (formerly React Query, now framework-agnostic) is built specifically for server state. It’s been the standard answer to this problem since version 3, and in 2026 — at version 5 — it’s even cleaner.

What It Actually Does

The core abstraction is simple: you describe a query with a key and a fetch function. TanStack Query handles everything else.

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

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  })

  if (isLoading) return <Skeleton />
  if (error) return <ErrorMessage error={error} />

  return <div>{data.name}</div>
}

That’s the minimal form. Behind it:

  • The first time this component mounts, the fetch runs
  • While the fetch is in progress, isLoading is true
  • If the fetch fails, error is populated
  • When the data arrives, it’s cached under the key ['user', userId]
  • If another component on the page also uses useQuery({ queryKey: ['user', userId] }), it gets the cached result immediately — no second request
  • When the user navigates away and comes back, the cached data renders instantly while a background refetch confirms it’s still current
  • If the user’s browser tab loses focus and then regains it, TanStack Query refetches automatically (configurable)

The queryKey is the unit of cache identity. It’s an array, and every element matters. ['user', '123'] and ['user', '456'] are separate cache entries.

The Problem with useEffect Fetching

The standard alternative, without a library:

// The naive approach — and why it's fragile
function UserProfile({ userId }: { userId: string }) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [userId])

  // ...
}

This looks fine until you notice:

  • If userId changes while the first fetch is in progress, both requests are in flight and the slower one might win (race condition)
  • There’s no deduplication — two components with the same userId make two requests
  • There’s no caching — navigating away and back triggers a full reload
  • Error handling is inconsistent — each component handles it differently
  • No background refresh — data goes stale silently

TanStack Query handles all of these by default. The race condition is fixed by the abort signal it passes to your fetch function. Deduplication and caching come from the query key. Background refresh is configurable.

Mutations

Reads are one side of the problem. Mutations — creating, updating, and deleting data — are the other.

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

function UpdateEmailForm({ userId }: { userId: string }) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (newEmail: string) =>
      fetch(`/api/users/${userId}/email`, {
        method: 'PATCH',
        body: JSON.stringify({ email: newEmail }),
        headers: { 'Content-Type': 'application/json' },
      }).then(res => res.json()),

    onSuccess: (updatedUser) => {
      // Option 1: invalidate the query — triggers a refetch
      queryClient.invalidateQueries({ queryKey: ['user', userId] })

      // Option 2: update the cache directly (no refetch needed if the server returned the full object)
      queryClient.setQueryData(['user', userId], updatedUser)
    },
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      mutation.mutate(new FormData(e.currentTarget).get('email') as string)
    }}>
      <input name="email" type="email" />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Saving...' : 'Update'}
      </button>
      {mutation.isError && <p>Failed to update. Try again.</p>}
    </form>
  )
}

useMutation tracks the mutation’s lifecycle — pending, success, error. useQueryClient lets you reach into the cache from anywhere to invalidate or update entries after a successful mutation.

Optimistic Updates

For forms that feel instant, optimistic updates update the cache before the server responds, then roll back on failure:

const mutation = useMutation({
  mutationFn: (newName: string) =>
    fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
      headers: { 'Content-Type': 'application/json' },
    }).then(res => res.json()),

  onMutate: async (newName) => {
    // Cancel any in-flight refetches that would overwrite our optimistic update
    await queryClient.cancelQueries({ queryKey: ['user', userId] })

    // Snapshot the current value for rollback
    const previousUser = queryClient.getQueryData(['user', userId])

    // Optimistically update the cache
    queryClient.setQueryData(['user', userId], (old: User) => ({
      ...old,
      name: newName,
    }))

    return { previousUser }
  },

  onError: (err, newName, context) => {
    // Roll back on failure
    queryClient.setQueryData(['user', userId], context?.previousUser)
  },

  onSettled: () => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

The UI updates the moment the user submits. If the server returns an error, the change is reverted and the user sees the failure message. The refetch in onSettled keeps the cache accurate regardless of outcome.

Query Configuration Worth Knowing

The defaults work for most cases, but a few configuration options matter:

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,

  // Don't refetch more often than every 5 minutes (default: 0 — always refetch on focus/reconnect)
  staleTime: 5 * 60 * 1000,

  // Keep unused data in cache for 10 minutes (default: 5 minutes)
  gcTime: 10 * 60 * 1000,

  // Retry failed requests up to 2 times (default: 3)
  retry: 2,

  // Don't refetch when the window regains focus (useful for infrequently changing data)
  refetchOnWindowFocus: false,

  // Disable the query entirely when a condition isn't met
  enabled: !!userId,
})

staleTime is the most commonly misunderstood option. With the default of 0, every query is considered stale immediately after it resolves — so any trigger (focus, reconnect, manual refetch) kicks off a background refresh. Setting staleTime: Infinity makes data effectively static until you invalidate it manually, which is appropriate for things like reference data that rarely changes.

The Setup

The provider wraps your app once:

// main.tsx or _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute default for all queries
      retry: 1,
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      {process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
    </QueryClientProvider>
  )
}

The devtools are worth installing in development. They show every query in the cache, its state (fresh/stale/fetching/error), the data, and the last fetch time. Debugging stale data issues becomes obvious rather than guesswork.

Framework Support in 2026

TanStack Query ships adapters for React, Vue, Angular, Solid, and Svelte. The mental model and API are consistent across them. The Vue adapter:

// Vue
import { useQuery } from '@tanstack/vue-query'

const { data, isLoading } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId.value),
})

For Next.js App Router, TanStack Query works alongside React Server Components. Server components fetch without it; client components that need interactive data management (search, pagination, real-time updates) use TanStack Query on the client. The two approaches aren’t in conflict.

What It Doesn’t Do

TanStack Query handles server state. It does not handle client-only state — which radio button is selected, whether a sidebar is open, what’s in a form field before submission. For that, useState, Zustand, or Jotai remain the right tools.

It also doesn’t replace a real caching layer. TanStack Query caches in memory, in the browser tab. A CDN or server-side cache for expensive API responses is a separate concern.

The library is also not an API client. It’s state management for async operations. You still write the fetch functions yourself, which is the right trade-off — it means you can use any HTTP client, any RPC mechanism, any API shape.

Why It’s Become Default

The reason TanStack Query spread so quickly is that it names the category. Before it, teams invented their own ad-hoc patterns for caching, deduplication, and background refresh, and they all had different bugs. The library makes the right behaviors the default behaviors, and it’s clear enough that teams actually understand what it’s doing.

In most React applications with a backend, the correct answer to “how do I fetch data” is now TanStack Query. The edge cases where it doesn’t fit are narrow enough that they’re worth evaluating explicitly rather than defaulting to useEffect.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored