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
8 min read
Sponsored
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,
isLoadingis true - If the fetch fails,
erroris 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
userIdchanges 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
userIdmake 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
More from this category
More from Web Development
OAuth 2.0 and PKCE: The Web Auth Patterns Every SPA Developer Needs in 2026
Technical SEO for JavaScript Apps in 2026: What Google Actually Renders
Progressive Web Apps in 2026: What Actually Works on iOS and Android
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.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored