TanStack Query v5 shipped with breaking API changes, new patterns, and a dramatically improved offline experience. If you've been using v4 or earlier, this guide bridges the gap and goes deep on the patterns that actually matter in production: optimistic updates with automatic rollback, complex multi-step mutations, and offline-first support with persistence.
What Changed in v5
- Single object API. All hooks now accept a single options object.
useQuery(['key'], fn, opts)→useQuery({queryKey: ['key'], queryFn: fn}) - Strongly typed errors.
erroris typed asError | nullby default (notunknown). useSuspenseQuery. First-class Suspense queries that guaranteedatais defined (no undefined checks).- Removed callbacks from
useQuery.onSuccess,onError,onSettledare removed — useuseMutationor effects. - Improved
mutation.variables. Variables are now available synchronously during mutation for optimistic rendering.
Optimistic Updates with Automatic Rollback
The gold standard: update the UI immediately, then sync with the server. If the server request fails, roll back automatically.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo { id: string; title: string; completed: boolean; }
function useTodoToggle() {
const qc = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !todo.completed }),
}).then(r => r.json()),
// Step 1: Snapshot + optimistic update
onMutate: async (todo) => {
// Cancel any in-flight refetches (avoid race conditions)
await qc.cancelQueries({ queryKey: ['todos'] });
// Snapshot the current value for rollback
const previous = qc.getQueryData<Todo[]>(['todos']);
// Optimistically update the cache
qc.setQueryData<Todo[]>(['todos'], old =>
old?.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t)
);
return { previous }; // ← context passed to onError
},
// Step 2: On failure — roll back to snapshot
onError: (_err, _todo, context) => {
if (context?.previous) {
qc.setQueryData(['todos'], context.previous);
}
},
// Step 3: Always refetch to ensure server is the source of truth
onSettled: () => {
qc.invalidateQueries({ queryKey: ['todos'] });
},
});
}
mutation.variables for Pending UI
In v5, mutation.variables is synchronously available while the mutation is pending. Combine with isIdle/isPending to show in-flight items immediately without touching the query cache.
function TodoList() {
const { data: todos = [] } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
const addMutation = useAddTodo();
// Render optimistic pending item alongside real data
return (
<ul>
{todos.map(t => <TodoItem key={t.id} todo={t} />)}
{addMutation.isPending && (
<TodoItem
key="optimistic"
todo={{ id: 'pending', title: addMutation.variables.title, completed: false }}
isPending
/>
)}
</ul>
);
}
Offline-First with persistQueryClient
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // Keep cache for 24h (was cacheTime in v4)
staleTime: 1000 * 60 * 5, // Consider fresh for 5 min
networkMode: 'offlineFirst', // Use cache when offline
},
mutations: {
networkMode: 'offlineFirst', // Queue mutations when offline
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'app-query-cache',
});
// Wrap your app
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
>
<YourApp />
</PersistQueryClientProvider>
);
}
With
networkMode: 'offlineFirst', mutations are queued when offline and automatically retried when connectivity is restored.
Query Key Factories (Best Practice)
// lib/queryKeys.ts
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: Filters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
};
// Usage — consistent keys everywhere
const { data } = useQuery({
queryKey: todoKeys.list({ status: 'open' }),
queryFn: () => fetchTodos({ status: 'open' }),
});
// Invalidate all todo lists
qc.invalidateQueries({ queryKey: todoKeys.lists() });
Summary
- v5 unified single-object API — update all hook call sites when migrating
- Optimistic updates:
onMutatesnapshot →onErrorrollback →onSettledinvalidate mutation.variablesenables zero-cache pending UI for instantly interactive UXpersistQueryClient+offlineFirst= seamless offline mutations without extra code- Query key factories eliminate key inconsistency bugs across large codebases