TanStack Query v5: What Changed and How to Migrate
·PkgPulse Team
TL;DR
TanStack Query v5 is a breaking change worth taking. The API was unified — isLoading, isError, isFetching are now consolidated via a single status/fetchStatus model that finally makes sense. Mutations got variables in callbacks. The useQuery overloads were simplified to a single object-based API. If you're on v4, migration is ~2-4 hours for most apps using the codemod. If you're on v3 or v2, migrate to v5 directly — the jump is worth skipping v4 entirely.
Key Takeaways
- Breaking API:
useQuery(key, fn, options)→useQuery({ queryKey, queryFn, ...options })— codemod handles this - Status simplification:
status(idle/loading/success/error) +fetchStatus(fetching/paused/idle) replace confusing v4 states - Optimistic updates:
optimisticResults/ newuseMutationpatterns are cleaner than v4'sonMutatehacks - Suspense: first-class with
useSuspenseQuery(replacessuspense: trueoption) - Bundle: v5 is ~10% smaller than v4 despite new features
The Biggest Change: Unified Query Options
// ─── v4 (3 overloads, all valid) ───
useQuery('todos', fetchTodos);
useQuery('todos', fetchTodos, { staleTime: 5000 });
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// ─── v5 (one way only) ───
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 5000 });
// The codemod handles migration:
npx @tanstack/react-query-codemods v5 ./src
// Why the change?
// → TypeScript inference was unreliable with multiple overloads
// → Easier to extend with new options without combinatorial explosion
// → Consistent with how the rest of the ecosystem moved (object params)
Status Model: Finally Makes Sense
// ─── v4 status confusion ───
// isLoading was true for BOTH:
// 1. First fetch (no cached data)
// 2. Background refetch (data exists but is refreshing)
// This caused subtle bugs where "loading" UI appeared during background updates
// ─── v5 status + fetchStatus ───
const { status, fetchStatus, data, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// status: 'pending' | 'success' | 'error'
// fetchStatus: 'fetching' | 'paused' | 'idle'
// The new mental model:
//
// "Is there data?" → status === 'success' (data is defined)
// "Is there an error?" → status === 'error' (error is defined)
// "Is it loading?" → status === 'pending' (no data yet)
// "Is it fetching?" → fetchStatus === 'fetching' (network request active)
// "Is it refetching?" → status === 'success' && fetchStatus === 'fetching'
// "Is it paused (offline)"? → fetchStatus === 'paused'
// Common patterns:
if (status === 'pending') return <Skeleton />;
if (status === 'error') return <ErrorMessage error={error} />;
// Show data immediately, indicate background refresh separately:
return (
<div>
{fetchStatus === 'fetching' && <RefreshIndicator />}
<UserProfile user={data} />
</div>
);
// Convenience booleans still work:
const { isLoading, isFetching, isError, isSuccess } = useQuery(...);
// isLoading = status === 'pending' && fetchStatus === 'fetching'
// (first fetch only — what you actually wanted from v4's isLoading)
Mutations: Variables in Callbacks
// ─── v4 — variables not in success/error callbacks ───
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) => api.createTodo(newTodo),
onSuccess: (data) => {
// What did we send? Had to capture in closure or read from form state
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// ─── v5 — variables available everywhere ───
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) => api.createTodo(newTodo),
onSuccess: (data, variables, context) => {
// variables = { title: "..." } — what was passed to mutate()
console.log(`Created: ${variables.title}`);
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
onError: (error, variables, context) => {
// Same — variables available on error
console.error(`Failed to create ${variables.title}:`, error);
},
});
// Call site:
mutation.mutate({ title: 'Buy groceries' });
// Access in component:
const { variables } = mutation;
// variables is typed as { title: string } | undefined
// Useful for showing optimistic UI based on what was submitted
// Optimistic updates pattern (v5 recommended approach):
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old: Todo[]) => [
...old,
{ id: 'temp-' + Date.now(), ...newTodo },
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Suspense: First-Class with useSuspenseQuery
// ─── v4 — suspense as an option (hacky) ───
function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
suspense: true, // 🚩 deprecated in v5
});
// data could still be undefined despite suspense — TypeScript didn't know
return <ul>{data?.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}
// ─── v5 — dedicated hook ───
import { useSuspenseQuery } from '@tanstack/react-query';
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// data is NEVER undefined here — TypeScript knows this
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}
// Wrap in Suspense + ErrorBoundary:
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Skeleton />}>
<TodoList />
</Suspense>
</ErrorBoundary>
);
}
// Multiple queries with useSuspenseQueries:
import { useSuspenseQueries } from '@tanstack/react-query';
function Dashboard() {
const [usersQuery, statsQuery] = useSuspenseQueries({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['stats'], queryFn: fetchStats },
],
});
// Both data guaranteed defined — parallel fetch, single Suspense boundary
return <DashboardView users={usersQuery.data} stats={statsQuery.data} />;
}
QueryClient Changes
// ─── v5: fetchQuery and prefetchQuery now throw on error ───
// v4: fetchQuery silently swallowed errors
// v5: errors propagate — handle them explicitly:
// Server-side prefetching (Next.js App Router):
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
async function TodosPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// v5: throws if fetchTodos rejects — wrap in try/catch for graceful handling
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{/* Hydration boundary is new in v5 — replaces Hydrate component */}
<TodoList />
</HydrationBoundary>
);
}
// Query invalidation — exact matching changed:
// v4: invalidateQueries(['todos']) matched ['todos', 'all'], ['todos', userId], etc.
// v5: same behavior, but explicit exact: true for exact match only:
queryClient.invalidateQueries({ queryKey: ['todos'] }); // matches all todos/*
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true }); // only ['todos']
// Removal of deprecated methods:
// v4 had: queryClient.getQueryData(key) with any[] key
// v5 requires: queryClient.getQueryData<Todo[]>(['todos']) — generic type required
New Devtools
// v5 devtools are lazy-loaded and significantly improved:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools
initialIsOpen={false}
// New in v5: floating button position
buttonPosition="bottom-right"
// New in v5: custom styles
styleNonce="your-nonce"
/>
</QueryClientProvider>
);
}
// v5 devtools new features:
// → Query explorer: visual tree of all cached queries
// → Timeline: see when queries were fetched/invalidated
// → Mutation history: list of all mutations with their variables/results
// → Quick refetch/invalidate/reset actions from the UI
// → Better TypeScript — devtools infer query data types
Migration Guide
# Step 1: Upgrade packages
npm install @tanstack/react-query@5 @tanstack/react-query-devtools@5
# Step 2: Run the codemod (handles ~80% of changes automatically)
npx @tanstack/react-query-codemods v5 ./src
# What the codemod does:
# ✓ Converts useQuery(key, fn) → useQuery({ queryKey: key, queryFn: fn })
# ✓ Converts useQuery(key, fn, options) → useQuery({ queryKey: key, queryFn: fn, ...options })
# ✓ Converts useMutation(fn) → useMutation({ mutationFn: fn })
# ✓ Converts useInfiniteQuery similarly
# ✗ Does NOT handle: custom hooks that wrap useQuery (review manually)
# ✗ Does NOT handle: QueryClient method calls (review manually)
# Step 3: Manual fixes for what the codemod misses:
# Replace Hydrate with HydrationBoundary:
# Before: import { Hydrate } from '@tanstack/react-query'
# After: import { HydrationBoundary } from '@tanstack/react-query'
# Replace suspense: true with useSuspenseQuery:
# Before: useQuery({ ..., suspense: true })
# After: useSuspenseQuery({ ... })
# Replace cacheTime with gcTime:
# Before: useQuery({ ..., cacheTime: 1000 * 60 * 5 })
# After: useQuery({ ..., gcTime: 1000 * 60 * 5 })
# (cacheTime was renamed to gcTime to better describe its purpose)
# Step 4: Fix TypeScript errors
# Run tsc after the codemod — remaining type errors indicate manual migration needed
tsc --noEmit
Breaking Changes Summary
v4 → v5 breaking changes (the complete list):
1. useQuery/useMutation/useInfiniteQuery: object-only API
→ codemod handles this
2. status 'loading' renamed to 'pending'
→ search for status === 'loading' and update
3. cacheTime renamed to gcTime
→ search and replace
4. Hydrate → HydrationBoundary
→ search and replace
5. suspense: true option removed → use useSuspenseQuery
→ manual: find suspense: true usages
6. isLoading semantics changed
→ isLoading now = status === 'pending' && fetchStatus === 'fetching' (first fetch only)
→ if you used isLoading for "any fetching in progress", use isFetching instead
7. remove() method removed from query results
→ use queryClient.removeQueries({ queryKey }) instead
8. QueryObserverResult changes
→ placeholderData now a function, not a value
→ Before: placeholderData: []
→ After: placeholderData: () => [] (or: placeholderData: keepPreviousData)
9. getNextPageParam / getPreviousPageParam are now required for infinite queries
(were optional in v4, had confusing undefined behavior)
Time estimate:
→ Small app (1-20 useQuery calls): 30 minutes
→ Medium app (20-100 calls): 2-4 hours
→ Large app (100+ calls): 4-8 hours + QA
Compare TanStack Query, SWR, and other data fetching libraries at PkgPulse.
See the live comparison
View tanstack query vs. swr on PkgPulse →