If you migrated from React Query v4 to v5 expecting a smooth ride, you probably hit at least one of these: your useQuery with suspense: true just stopped working, your invalidateQueries wiped more cache than you intended, or your persisted cache hydrated stale data and you shipped a bug to production.
TanStack Query v5 is a near-complete rewrite in terms of API surface. The concepts are the same. The defaults, the options, the hook names — not so much. This article is the guide I wish existed when I made the jump: practical, opinionated, and dense with real patterns you’ll actually use.
Official repo: https://github.com/TanStack/query
Why v5 Matters (and Why It Breaks Things)
The v5 release wasn’t just a version bump. The team removed the old object-overload signatures, killed the suspense boolean on useQuery, unified the status model, and introduced first-class Suspense hooks. If your codebase used useQuery({ suspense: true }) you need to migrate.
The upside: the new API is dramatically more predictable. The downside: your muscle memory is wrong.
Let’s cover the three areas where developers spend the most time debugging.
Part 1: Invalidation Patterns
Cache invalidation is the hard problem in frontend state management. TanStack Query’s model is simple in theory — invalidate a key, the query refetches — but in practice you need surgical precision.
The Basic Mental Model
When you call invalidateQueries, you’re marking matching queries as stale. If the query is currently rendered (has active observers), it refetches immediately. If it’s not rendered, it refetches next time a component mounts with that query.
// Marks ALL queries as stale
queryClient.invalidateQueries()
// Marks queries whose key starts with 'posts'
queryClient.invalidateQueries({ queryKey: ['posts'] })
The second example uses fuzzy matching by default. ['posts'] will match ['posts'], ['posts', 1], ['posts', { status: 'published' }] — everything that starts with that prefix. This is intentional and useful.
Exact Matching
Sometimes you only want to invalidate one specific query, not everything under a namespace. Use exact: true:
// Only invalidates ['posts', 42] — not ['posts'] or ['posts', 42, 'comments']
queryClient.invalidateQueries({
queryKey: ['posts', 42],
exact: true,
})
Without exact, updating a single post could trigger a refetch of your entire posts list. That’s usually not what you want after a PATCH request.
Predicate-Based Invalidation
The most powerful pattern. Use a predicate function to invalidate based on arbitrary query state:
queryClient.invalidateQueries({
predicate: (query) => {
// Invalidate all queries that fetched more than 5 minutes ago
// and belong to the 'posts' namespace
const isPostsQuery = query.queryKey[0] === 'posts'
const isOld = Date.now() - query.state.dataUpdatedAt > 5 * 60 * 1000
return isPostsQuery && isOld
},
})
This is invaluable after WebSocket events where you know something changed but don’t know the exact query key. Match on whatever you do know.
The refetchType Option
Here’s the one that trips people up. By default, invalidateQueries only immediately refetches active queries (ones with mounted components observing them). Inactive queries get marked stale but don’t refetch until next mount.
Control this explicitly:
// Default: only refetch active queries right now
queryClient.invalidateQueries({ queryKey: ['posts'], refetchType: 'active' })
// Refetch everything — active and inactive
queryClient.invalidateQueries({ queryKey: ['posts'], refetchType: 'all' })
// Just mark as stale, don't refetch anything yet
queryClient.invalidateQueries({ queryKey: ['posts'], refetchType: 'none' })
Use refetchType: 'none' when you’re doing bulk invalidations after a complex mutation and you want to control exactly when refetches happen — not let TanStack Query fire them all simultaneously.
Invalidation After Mutations
The canonical pattern in v5 is onSuccess → invalidateQueries. But there’s a subtlety: by default, mutateAsync resolves before the invalidation refetch completes. If you’re navigating to a detail page after a create mutation, you might render before the list refetch finishes.
const createPost = useMutation({
mutationFn: (data: CreatePostInput) => api.posts.create(data),
onSuccess: async (newPost) => {
// Awaiting this ensures the list refetch finishes before onSuccess resolves
await queryClient.invalidateQueries({ queryKey: ['posts'] })
// Now navigate — the list cache is already fresh
router.push(`/posts/${newPost.id}`)
},
})
The await on invalidateQueries is not just style — it changes behavior. Without it, you’re fire-and-forget on the refetch.
Optimistic Updates + Invalidation
For snappy UIs, update the cache immediately and invalidate on completion:
const updatePost = useMutation({
mutationFn: (update: { id: number; title: string }) =>
api.posts.update(update.id, { title: update.title }),
onMutate: async (update) => {
// Cancel in-flight refetches to avoid overwriting our optimistic update
await queryClient.cancelQueries({ queryKey: ['posts', update.id] })
// Snapshot the current value for rollback
const previous = queryClient.getQueryData<Post>(['posts', update.id])
// Optimistically update
queryClient.setQueryData<Post>(['posts', update.id], (old) =>
old ? { ...old, title: update.title } : old
)
return { previous }
},
onError: (_err, update, context) => {
// Roll back on failure
if (context?.previous) {
queryClient.setQueryData(['posts', update.id], context.previous)
}
},
onSettled: (_, __, update) => {
// Always invalidate after settle to sync with server truth
queryClient.invalidateQueries({ queryKey: ['posts', update.id] })
},
})
The cancelQueries call before the optimistic update is critical. If you skip it, a concurrent background refetch can land after your optimistic update and revert the UI to the old state.
Gotcha: onSettled runs on both success and error. Put your invalidation there, not in onSuccess. This ensures you always re-sync with the server, even after errors.
Part 2: Persistence
Persisting the query cache lets you show stale-but-correct data instantly on app load, then quietly refresh in the background. Done right, it feels like the app is instant. Done wrong, you ship stale data that your users trust.
The Architecture
TanStack Query’s persistence layer works through two pieces:
- A persister (adapter for the storage backend)
PersistQueryClientProvider(wraps your app and handles hydration)
The cache is dehydrated (serialized) to storage and hydrated (deserialized) on load.
Setting Up Sync Storage (localStorage)
// persistence.ts
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { QueryClient } from '@tanstack/react-query'
import { persistQueryClient } from '@tanstack/react-query-persist-client'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// How long dehydrated (persisted) data is considered fresh
// before triggering a background refetch on hydration
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
// Throttle writes — don't serialize the entire cache on every query update
throttleTime: 1000,
})
persistQueryClient({
queryClient,
persister: localStoragePersister,
// Persisted data older than this is discarded on load
maxAge: 1000 * 60 * 60 * 24, // 24 hours
})
// App.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { queryClient, localStoragePersister } from './persistence'
export function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: localStoragePersister }}
>
<Router />
</PersistQueryClientProvider>
)
}
Async Storage (React Native / IndexedDB)
For React Native or when you need IndexedDB:
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
throttleTime: 1000,
})
The API is identical — swap the persister, the rest of your code stays the same.
Selective Persistence with dehydrateOptions
Persisting everything is a trap. User-specific data, sensitive tokens, large blob responses — none of that belongs in localStorage. Filter what gets persisted:
persistQueryClient({
queryClient,
persister: localStoragePersister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist queries that opted in via their meta
return query.meta?.persist === true
},
},
})
Then on the query itself:
useQuery({
queryKey: ['config', 'app'],
queryFn: fetchAppConfig,
meta: { persist: true }, // This one gets persisted
})
useQuery({
queryKey: ['user', 'session'],
queryFn: fetchUserSession,
// No meta.persist — stays in memory only
})
Gotcha: Don’t persist auth tokens or session data in localStorage. If an XSS vulnerability exists anywhere on your domain, that data is gone. Persist configuration, reference data, and public content — not secrets.
Gotcha: localStorage has a ~5MB cap. If you’re caching large lists or binary data, you’ll blow past it silently and the write will fail. The persister will catch the error, but your users lose their cache. Use IndexedDB via idb-keyval for larger payloads.
The isRestoring State
On app load, the persisted cache isn’t hydrated synchronously. There’s a brief window where isRestoring is true. Render a skeleton or the app shell during this time — don’t try to read query data yet:
import { useIsRestoring } from '@tanstack/react-query'
function AppShell({ children }: { children: React.ReactNode }) {
const isRestoring = useIsRestoring()
if (isRestoring) {
return <GlobalSkeleton />
}
return <>{children}</>
}
Without this guard, components will flash with empty state before hydration completes, even if you have valid persisted data.
Production Cache Versioning
After a deployment that changes your data shapes, old persisted cache is a liability. Version your cache and bust it on breaking changes:
persistQueryClient({
queryClient,
persister: localStoragePersister,
buster: 'v3', // Change this string to invalidate all persisted caches
})
When buster changes, all previously persisted data is discarded and a fresh fetch happens. Wire this to your build version or a manually incremented constant.
Part 3: Suspense Mode
Suspense support in v5 is first-class, not bolted on. There are dedicated hooks, and the old suspense: true flag on useQuery is gone.
The New Hooks
// v4 (gone in v5)
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, suspense: true })
// v5
const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: fetchPosts })
The key behavioral difference: useSuspenseQuery always returns data as defined (not undefined). The type is Post[], not Post[] | undefined. No null-checks needed. The hook suspends until data is available, so by the time your component renders, data exists.
This is the API design that should have existed from day one.
Wiring Up Error Boundaries
Suspense is useless without error boundaries. Errors thrown during fetch bubble up to the nearest ErrorBoundary:
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
function PostsPage() {
return (
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<PostsSkeleton />}>
<PostsList />
</Suspense>
</ErrorBoundary>
)
}
function PostsList() {
// data is Post[] — TypeScript knows it's defined
const { data: posts } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Gotcha: React’s default error boundary resets only on key changes. Use react-error-boundary‘s resetKeys to allow retry without a full page reload:
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
function PostsPage() {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>Something went wrong loading posts.</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<Suspense fallback={<PostsSkeleton />}>
<PostsList />
</Suspense>
</ErrorBoundary>
)
}
The useQueryErrorResetBoundary call resets the error state in TanStack Query’s cache when the boundary resets — without it, clicking "Retry" triggers another error immediately because the query is still in an error state.
Parallel Suspense Queries
When multiple queries need to suspend, naive nesting creates a waterfall. Use useSuspenseQueries to fire them in parallel:
function Dashboard() {
const [{ data: user }, { data: stats }, { data: notifications }] =
useSuspenseQueries({
queries: [
{ queryKey: ['user', 'profile'], queryFn: fetchUserProfile },
{ queryKey: ['user', 'stats'], queryFn: fetchUserStats },
{ queryKey: ['notifications'], queryFn: fetchNotifications },
],
})
// All three resolved — render the full dashboard
return <DashboardLayout user={user} stats={stats} notifications={notifications} />
}
All three requests fire simultaneously. The component suspends until all resolve. This is the correct pattern whenever you need multiple independent pieces of data before rendering.
Suspense with Infinite Queries
function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSuspenseInfiniteQuery({
queryKey: ['posts', 'feed'],
queryFn: ({ pageParam }) => fetchPostsPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
return (
<div>
{data.pages.flatMap((page) => page.items).map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</div>
)
}
Note initialPageParam — this is a breaking change from v4. The first page param must be explicit. undefined is no longer the implicit default.
Streaming SSR with Next.js App Router
TanStack Query v5’s Suspense hooks compose cleanly with React’s streaming SSR. Prefetch on the server, dehydrate, hydrate on the client:
// app/posts/page.tsx (Next.js App Router)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
// HydrationBoundary transfers server-fetched data to the client cache
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PostsSkeleton />}>
<PostsList />
</Suspense>
</HydrationBoundary>
)
}
The client component (PostsList) uses useSuspenseQuery with the same key. On first render, it finds data already in the cache from the server — no suspend, no waterfall. Background refetches happen normally after hydration.
Gotcha: Create a new QueryClient per request on the server. A shared singleton leaks data between requests and is a security issue.
Production Checklist
Before shipping any of this to production, run through these:
Invalidation:
- Are you using
exact: truefor single-entity invalidations? - Are mutations
await-ing invalidation before navigating? - Is
onSettledused instead ofonSuccessfor cache sync? - Are optimistic updates calling
cancelQueriesfirst?
Persistence:
- Is sensitive data excluded via
shouldDehydrateQuery? - Do you have a cache
busterwired to your deployment version? - Is
isRestoringguarded in your app shell? - Have you tested the storage size ceiling for your cached data?
Suspense:
- Is every
useSuspenseQuerywrapped in bothSuspenseandErrorBoundary? - Are parallel queries using
useSuspenseQueriesto avoid waterfalls? - Is
useQueryErrorResetBoundarywired to your error boundaries? - Are you creating per-request
QueryClientinstances on the server (not a singleton)?
Closing Thoughts
TanStack Query v5 is genuinely better than v4. The Suspense API is clean, the persistence layer is composable, and the invalidation model is explicit in all the ways that matter. The migration pain is real but front-loaded — get through it once and you won’t look back.
The patterns here cover the majority of production scenarios. When in doubt, check the query’s state directly via devtools before assuming the cache is doing what you think it’s doing. The TanStack Query devtools are exceptional and will save you hours of debugging on caching issues that look like network issues and vice versa.