TanStack Query v5 Guide: Mutations, Optimistic Updates, Prefetching
TanStack Query v5 rewrites the mutation and caching APIs. Here's how to actually use mutations, optimistic updates, and prefetching without losing your mind.
What Actually Changed in v5 (And Why It Matters)
TanStack Query v5 shipped in late 2023 and it wasn't a small patch — it rewrote a big chunk of the public API surface. The useQuery signature changed. cacheTime became gcTime. The remove() method on query results is gone. And if you were using onSuccess, onError, or onSettled callbacks directly on useQuery, those are gone too. Fully removed. Not deprecated — gone.
That last one trips people up the most. In v4 you'd do useQuery({ ..., onSuccess: (data) => doSomething(data) }). In v5, those callbacks only live on useMutation now. For queries, you react to state changes in the render cycle or inside a useEffect. Honestly, this is a better mental model — query callbacks were always a bit awkward because they fired per-observer, not per-cache-entry — but migrating existing codebases stings.
The other big headline: the status field is more granular now. There's a separate fetchStatus property ('fetching' | 'paused' | 'idle') that decouples *are we currently fetching* from *what data state are we in*. So status === 'loading' is now status === 'pending' and you can differentiate between a query that has never loaded versus one that's silently refetching in the background. Worth noting: these two axes together give you a 3×2 matrix of possible states. Read the official status guide before you start asserting on isLoading — it means something subtly different now.
Quick aside: if you're upgrading an existing app, run the official v5 codemod first: npx jscodeshift@latest --extensions=ts,tsx --transform node_modules/@tanstack/react-query/build/codemods/v5/remove-overloads/remove-overloads.cjs ./src. It won't fix everything but it handles the high-volume mechanical changes.
Mutations: The Right Mental Model
A mutation is a one-shot action — create, update, delete — that intentionally has side effects. It doesn't live in cache the same way a query does. You fire it, it runs, and it's done. useMutation gives you a function to trigger that action and a bunch of state about the last invocation. That's it.
Here's a minimal but real-world example — posting a new comment on a post:
``tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Comment {
id: string;
postId: string;
body: string;
author: string;
}
async function createComment(data: Omit<Comment, 'id'>): Promise<Comment> {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create comment');
return res.json();
}
function CommentForm({ postId }: { postId: string }) {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: createComment,
onSuccess: () => {
// Invalidate so the comment list refetches
qc.invalidateQueries({ queryKey: ['comments', postId] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
mutation.mutate({
postId,
body: (form.elements.namedItem('body') as HTMLTextAreaElement).value,
author: 'current-user',
});
}}
>
<textarea name="body" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Posting...' : 'Post comment'}
</button>
{mutation.isError && <p>{mutation.error.message}</p>}
</form>
);
}
``
Notice mutation.isPending — that's the v5 name for what v4 called isLoading. The onSuccess callback fires after the server confirms success and you get the response body as its first argument. This is where you invalidate or update the cache. Don't do cache surgery inside the form handler; put it in onSuccess where you have access to both the variables and the fresh data.
In practice, mutateAsync (the promise-returning variant) is useful when you need to await the result before navigating or showing a toast. But mutate (fire-and-forget) is fine for most form submissions. One more thing — if you're managing a global mutation state (like a loading indicator in a navbar), use useIsMutating() to count active mutations without coupling UI to specific mutation instances.
Optimistic Updates That Don't Break Everything
Optimistic updates make your UI feel instant — you update the cache before the server responds, and roll back if the request fails. They're great UX. They're also where most developers introduce subtle bugs. The pattern requires three pieces working together: onMutate, onError, and onSettled.
Here's the full pattern for optimistically toggling a todo's completion:
``tsx
const toggleTodo = useMutation({
mutationFn: (todo: Todo) =>
fetch(/api/todos/${todo.id}, {
method: 'PATCH',
body: JSON.stringify({ done: !todo.done }),
}).then((r) => r.json()),
onMutate: async (newTodo) => {
// 1. Cancel any outgoing refetches so they don't overwrite our optimistic update
await qc.cancelQueries({ queryKey: ['todos'] });
// 2. Snapshot the previous value
const previousTodos = qc.getQueryData<Todo[]>(['todos']);
// 3. Optimistically update to the new value
qc.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map((t) => (t.id === newTodo.id ? { ...t, done: !t.done } : t))
);
// 4. Return context with the snapshot
return { previousTodos };
},
onError: (_err, _newTodo, context) => {
// Roll back on failure
if (context?.previousTodos) {
qc.setQueryData(['todos'], context.previousTodos);
}
},
onSettled: () => {
// Always refetch after error or success to sync with server
qc.invalidateQueries({ queryKey: ['todos'] });
},
});
``
The await qc.cancelQueries() call in onMutate is the step people skip — and then they spend 20 minutes debugging why their UI flickers back. Without it, an in-flight background refetch can land *after* your optimistic update and overwrite it. Always cancel first.
Look, the rollback in onError is only as reliable as your snapshot. If your data is deeply nested and you're doing partial mutations on slices of it, make sure your snapshot captures everything that mutation could touch. Shallow snapshots of complex cache shapes cause subtle rollback bugs where the UI is 90% correct after a failure. That's worse than being visibly wrong because users won't notice and you'll ship a broken state.
That said, not every mutation needs to be optimistic. Low-frequency, high-consequence operations — deleting an account, submitting a payment — should wait for server confirmation. Save the optimistic pattern for interactions where speed is noticeable and rollbacks are low-stakes: likes, toggles, reordering lists, marking notifications read.
Prefetching: Load Data Before Users Ask For It
Prefetching is how you make navigation feel instant. You load query data into cache *before* the user navigates to a page, so by the time they get there, the data is already sitting in memory. TanStack Query v5 gives you two main tools for this: queryClient.prefetchQuery() and queryClient.prefetchInfiniteQuery().
The most common pattern is hovering over a link. When the user's cursor lands on a nav item, you've got ~200ms before they click — plenty of time to kick off a prefetch:
``tsx
import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
const postDetailOptions = (id: string) => ({
queryKey: ['post', id],
queryFn: () => fetch(/api/posts/${id}).then((r) => r.json()),
staleTime: 30_000,
});
function PostLink({ post }: { post: { id: string; title: string } }) {
const qc = useQueryClient();
return (
<Link
to={/posts/${post.id}}
onMouseEnter={() =>
qc.prefetchQuery(postDetailOptions(post.id))
}
>
{post.title}
</Link>
);
}
``
Worth noting: prefetchQuery respects the existing cache. If the data is already fresh (within staleTime), it won't fire a network request. So you can call it aggressively on hover without hammering your API — it's idempotent on warm cache. The only cost of calling it on an already-cached key is a synchronous cache lookup.
For server-side rendering with Next.js App Router, the pattern shifts to HydrationBoundary. You prefetch on the server, dehydrate the cache, and rehydrate it on the client:
``tsx
// app/posts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function PostsPage() {
const qc = new QueryClient();
await qc.prefetchQuery({
queryKey: ['posts'],
queryFn: () => fetch('https://api.example.com/posts').then((r) => r.json()),
});
return (
<HydrationBoundary state={dehydrate(qc)}>
<PostList />
</HydrationBoundary>
);
}
``
This replaces the v4 dehydrate/Hydrate pair. The HydrationBoundary component is new in v5 and it's cleaner — you can nest multiple boundaries for different data slices at different points in your component tree. No more single root-level hydration blob.
Cache Configuration: staleTime, gcTime, and When to Use Each
Two numbers control caching behavior. staleTime is how long data stays considered fresh — during this window, TanStack Query won't refetch on mount or window focus. gcTime (formerly cacheTime) is how long *inactive* data stays in memory before garbage collection. They serve completely different purposes and people constantly mix them up.
Default values as of v5: staleTime is 0 (data is immediately stale), gcTime is 5 * 60 * 1000 (5 minutes). So by default, every mount refetches — because staleTime: 0 means data is stale the instant it arrives. This is a safe default but it's not right for every endpoint. A list of US states doesn't need refetching every render. A live order status probably should refetch every 10 seconds.
Here's a practical configuration pattern:
``ts
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 minute global default
gcTime: 5 * 60_000, // 5 minutes in memory after unmount
retry: 1, // retry once on failure
refetchOnWindowFocus: false, // opt out of the aggressive default
},
},
});
``
Then you can override per-query for endpoints that need different behavior. Real-time dashboards want staleTime: 0 and refetchInterval: 5000. Lookup data (countries, currencies, product categories) is fine with staleTime: Infinity. Building a UI with components that have wildly different freshness needs? Check out how Empire UI handles data-driven component state in browse components — it's a good reference for separating static config from live data.
One more thing — gcTime should always be greater than or equal to staleTime. If gcTime is shorter, you'd garbage-collect data before it goes stale, which means cache misses on data that should have been hot. Set gcTime to at least 2× your staleTime as a rule of thumb.
Query Keys: Structure Them or Suffer Later
Query keys are how TanStack Query identifies cache entries. They're arrays and they support hierarchical invalidation — qc.invalidateQueries({ queryKey: ['posts'] }) blows away ['posts'], ['posts', 'list'], ['posts', '42'], and anything else starting with 'posts'. This is powerful, but only if you design your key structure with that hierarchy in mind from day one.
The pattern that scales best is a query key factory:
``ts
// queryKeys.ts
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: Record<string, unknown>) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
comments: (postId: string) => [...postKeys.detail(postId), 'comments'] as const,
};
// Usage
useQuery({ queryKey: postKeys.detail('42'), queryFn: ... });
// Invalidate all post lists after creating a post
qc.invalidateQueries({ queryKey: postKeys.lists() });
// Invalidate everything posts-related after a bulk operation
qc.invalidateQueries({ queryKey: postKeys.all });
``
Honestly, the query key factory pattern feels like over-engineering until you've debugged a cache invalidation bug in production at 2am. After that you'll use it everywhere. The 30 minutes you spend designing the key hierarchy upfront saves hours of wtf-is-still-in-cache debugging later.
Avoid putting mutable objects directly in query keys. Objects compare by reference in JavaScript, so { userId: 1 } and { userId: 1 } are not equal — you'll get cache misses on every render. Stick to primitives and sorted, serializable values. If you need to key on a complex filter object, stringify it or extract the relevant scalar fields.
Building Fast UIs With These Patterns Together
The real payoff comes when you combine these patterns. Prefetch on hover, optimistically update on interaction, invalidate precisely on success. A list of items where you can toggle, edit, and delete each one — with zero loading spinners and instant feedback — is completely achievable with TanStack Query v5 without reaching for any extra state management library.
When you're building the component layer on top of these patterns, starting with a solid component library saves a ton of time. Empire UI ships with pre-built glassmorphism components and dozens of other component styles that pair well with data-driven patterns — you're not fighting generic unstyled components while also wiring up query logic.
One pattern worth calling out: suspense mode. TanStack Query v5 has first-class Suspense support via useSuspenseQuery. It throws a Promise during loading (which React's Suspense boundary catches) and never returns undefined — the data is always defined when the component renders. This makes TypeScript types cleaner and eliminates entire categories of null-check boilerplate:
``tsx
// With useSuspenseQuery, data is always Post — never Post | undefined
function PostDetail({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ['post', id],
queryFn: () => fetch(/api/posts/${id}).then(r => r.json()),
});
// data.title is safe without optional chaining
return <h1>{data.title}</h1>;
}
``
What does your error boundary story look like? If you're using Suspense for loading states, pair it with React's ErrorBoundary (or react-error-boundary) for errors — otherwise unhandled query errors will crash your tree silently in production. The combination of <ErrorBoundary> wrapping <Suspense> gives you clean separation of error UI from loading UI without any manual if (isError) branches.
FAQ
staleTime controls when data is considered outdated and eligible for a background refetch. gcTime controls how long unused cache entries stay in memory before being deleted. They're independent — you can have long gcTime with short staleTime (data stays in memory but refetches often) or the reverse.
Those callbacks fired per-observer, not per-cache-entry, so in multi-instance scenarios they'd trigger multiple times unexpectedly. The v5 team moved them to useMutation only (where they make more sense) and pushed query-level side effects to useEffect or derived state in the render cycle.
Snapshot the current cache value in onMutate, store it in the returned context object, then restore it in onError via qc.setQueryData. Always follow up in onSettled with an invalidateQueries call so the UI eventually syncs to actual server state regardless of success or failure.
No — prefetchQuery checks if data exists and is within its staleTime window. If it is, it skips the network call entirely. You can call it on hover without worrying about hammering your API; it's safe to call repeatedly on warm cache entries.