TanStack Query v5 in React: Data Fetching That Actually Scales
TanStack Query v5 changes how React apps handle server state. Here's a practical guide to queries, mutations, caching, and patterns that hold up in production.
Why You Still Need TanStack Query in 2026
React Server Components haven't killed TanStack Query. That's the headline. You'd think with Next.js 15's async server components and the use hook landing in React 19, the library would be fading into legacy status — but it hasn't, and there's a real reason for that.
Client-side data — things like paginated lists, optimistic mutations, real-time invalidation, background refetching on window focus — is still fundamentally a different problem from server rendering. TanStack Query v5 (released late 2023) solved the server-state problem in a way that RSCs don't touch. It's not a competitor to server fetching; it sits on top of it.
In practice, the apps where you reach for TanStack Query are the ones with dashboards, user-specific data, forms that hit APIs, infinite scroll, or anything where stale data is actually a problem your users notice. That's most SaaS apps. That's most admin UIs. Honestly, if you're building anything beyond a static marketing site, you probably want this library.
Worth noting: v5 dropped the callback-based API entirely. No more onSuccess / onError on useQuery. If you're migrating from v4, that's the biggest breaking change. But the new API is cleaner once you adjust.
Setting Up v5: The Basics Haven't Changed Much
Install and wrap your app. That's step one, and it's the same as it's always been.
npm install @tanstack/react-query
# Optional but recommended for devtools
npm install @tanstack/react-query-devtoolsimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 2,
},
},
})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}The staleTime default is 0, which means every component mount triggers a background refetch. That's aggressive. For most apps, setting a global staleTime of 30–60 seconds cuts unnecessary network traffic significantly. Tweak per-query for anything where freshness matters more.
Quick aside: the devtools panel is genuinely useful. You can see exactly which queries are stale, fetching, or cached, which saves a lot of console.log spelunking when something's not updating when you expect it to.
useQuery: Patterns That Hold Up Under Load
The core API is useQuery. You give it a query key and a query function. That's it. But how you structure query keys is where a lot of teams go wrong early.
import { useQuery } from '@tanstack/react-query'
// Good: hierarchical key structure
const { data, isLoading, error } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes for user data
enabled: !!userId, // Don't run if userId is undefined
})The enabled option is one of the most underused features. It lets you make queries conditional without putting if statements around hooks, which would violate the Rules of Hooks. You'll reach for it constantly — dependent queries, auth-gated fetches, data that only loads after a user action.
// Dependent query pattern
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchCurrentUser,
})
const { data: preferences } = useQuery({
queryKey: ['preferences', user?.id],
queryFn: () => fetchPreferences(user!.id),
enabled: !!user?.id, // Only fires after user loads
})One more thing — the select option lets you transform or filter the response right inside the query config. It doesn't affect the cache (the full response is still stored), but the component only re-renders when the *selected* value changes. For a list where you only care about count, that's a meaningful optimization at scale.
Mutations and Optimistic Updates
Mutations are where TanStack Query v5 made the biggest ergonomic shift. The onSuccess and onError callbacks moved off useMutation and onto the mutate call itself. This actually makes more sense — the side effects belong at the call site, not buried in a hook definition far from where the user triggered the action.
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(r => r.json()),
})
// In your component
mutation.mutate(
{ title: 'Ship the feature' },
{
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error) => {
toast.error('Failed to create todo')
},
}
)Optimistic updates are a bit more involved but the pattern is solid once you've done it once. You update the cache *before* the server responds, then roll back if the mutation fails.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', updatedTodo.id] })
// Snapshot the previous value
const previous = queryClient.getQueryData(['todos', updatedTodo.id])
// Optimistically update
queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo)
return { previous }
},
onError: (err, updatedTodo, context) => {
// Roll back on failure
queryClient.setQueryData(
['todos', updatedTodo.id],
context?.previous
)
},
onSettled: (data, error, variables) => {
// Always refetch after mutation to sync with server
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] })
},
})Look, optimistic updates feel like overkill until you build an app where users are clicking buttons and waiting 400ms to see anything change. Then it becomes the difference between "feels broken" and "feels instant." The rollback logic is verbose, yes, but it's also explicit — you always know what happens on failure.
Infinite Queries and Pagination
Infinite scroll is one of those features that's genuinely hard to get right without a library. useInfiniteQuery handles the page-fetching state machine so you don't have to.
import { useInfiniteQuery } from '@tanstack/react-query'
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['components', filters],
queryFn: ({ pageParam }) =>
fetchComponents({ page: pageParam, ...filters }),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
})
// Flatten pages into a single array
const allComponents = data?.pages.flatMap(page => page.items) ?? []Note initialPageParam — that's v5-specific. In v4 it was getNextPageParam returning undefined on the first call. The new signature is cleaner and more explicit about what the first page looks like.
One thing worth calling out: the cache key includes filters. That means if the user changes a filter, they get a fresh infinite query, not a weird mashup of old and new data. Keeping filters in the query key is one of those patterns you learn the hard way if you don't do it from the start.
For traditional offset pagination (page 1, 2, 3 with prev/next buttons), you're better off with plain useQuery and a page variable in the key. useInfiniteQuery has extra overhead that doesn't make sense when you're not actually appending data.
Query Keys at Scale: The Factory Pattern
Once you have 20+ queries in an app, ad-hoc string arrays in query keys become a maintenance problem. The query key factory pattern fixes this. You define all your keys in one place, and everywhere in the codebase points to that object.
// queries/componentKeys.ts
export const componentKeys = {
all: ['components'] as const,
lists: () => [...componentKeys.all, 'list'] as const,
list: (filters: ComponentFilters) =>
[...componentKeys.lists(), filters] as const,
details: () => [...componentKeys.all, 'detail'] as const,
detail: (id: string) =>
[...componentKeys.details(), id] as const,
}
// Usage in components
useQuery({
queryKey: componentKeys.detail(componentId),
queryFn: () => fetchComponent(componentId),
})
// Invalidate all lists without touching detail caches
queryClient.invalidateQueries({ queryKey: componentKeys.lists() })This scales. When you need to invalidate an entire section of the cache — say, all component queries — you do invalidateQueries({ queryKey: componentKeys.all }) and you're done. No hunting through files to find matching key strings.
Honestly, this is the pattern I wish someone had told me about in 2021 instead of finding it buried in a GitHub discussion. Start with it from day one.
Integrating With Your UI Layer
TanStack Query doesn't care about your UI framework, which is one of its strengths. But there are some patterns worth knowing when you're building with component libraries or design systems. If you're wiring up data fetching to something like the components you'd find browsing Empire UI, the loading and error states are where you spend most of your time.
A common pattern is a custom hook that wraps useQuery and returns typed states your components can act on without knowing anything about TanStack internals:
function useComponents(filters: ComponentFilters) {
const query = useQuery({
queryKey: componentKeys.list(filters),
queryFn: () => fetchComponents(filters),
staleTime: 2 * 60 * 1000,
})
return {
components: query.data?.items ?? [],
total: query.data?.total ?? 0,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
}
}Combine this with a good skeleton/loading state design — the kind you'd build with a glassmorphism generator or a proper design token system — and your loading states look intentional rather than afterthought. That 16px gap between skeleton rows matters more than people think.
For error states, query.error in v5 is typed as Error by default. If your API returns structured error objects, use the throwOnError option or a custom error serializer so your error UI has the data it needs. Don't just render error.message and call it a day — that string is for developers, not users.
That said, don't over-abstract. I've seen codebases where every query went through four layers of wrappers and nobody could trace what was happening. Keep your custom hooks thin. The point is to hide TanStack-specific types at the component boundary, not to build a framework on top of a framework.
FAQ
The biggest breaking change is that onSuccess, onError, and onSettled callbacks were removed from useQuery. Error handling now belongs in useEffect or at the mutation call site. The initialPageParam option was also added to useInfiniteQuery to make the first page fetch explicit.
They solve different problems. RSCs handle initial server-rendered data; TanStack Query handles client-side state that needs caching, revalidation, and mutation tracking. In a Next.js 15 app you'll often use both — RSCs for the initial render, TanStack Query for interactive data after hydration.
Call queryClient.invalidateQueries({ queryKey: yourKeyFactory.all }) in the onSettled callback of your mutation. Using a query key factory makes this trivially precise — you can invalidate one list, one detail, or an entire resource group without touching unrelated caches.
If you have more than two or three useEffect data fetches, yes. The setup cost is minimal — a QueryClientProvider wrapper and a few lines of config. The devtools alone save time. Don't wait until the app is large to add it.