Skip to main content

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 / new useMutation patterns are cleaner than v4's onMutate hacks
  • Suspense: first-class with useSuspenseQuery (replaces suspense: true option)
  • 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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.