Skip to main content

TanStack Query v5: What Changed and How to Migrate 2026

·PkgPulse Team
0

Why TanStack Query v5 Was a Necessary Breaking Change

TanStack Query (formerly React Query) accumulated significant API debt across v2, v3, and v4. The core data fetching logic worked well, but the interface had grown organically in ways that created real problems. The useQuery hook had three different calling conventions — query key alone, key + function, and the object form — and TypeScript inference behaved differently depending on which one you used. The status field mixed concerns that should have been separate: whether data existed and whether a network request was in progress. The suspense: true option worked but wasn't type-safe, which meant TypeScript couldn't tell you that data would never be undefined inside a Suspense boundary.

Tanner Linsley, the library's creator, decided to address these issues holistically rather than through backward-compatible patches. The result is v5: a cleaner, more consistent API that's strictly better than v4 for new code, with automated migration tooling (the codemod) that handles the mechanical parts of the upgrade for existing codebases.

The codemod's existence is significant. It signals that the TanStack team treated the migration seriously enough to build automation for it, and in practice the codemod handles roughly 80% of the required changes automatically. For most applications, the remaining 20% involves four or five specific patterns — suspense: true, cacheTime, Hydrate, status === 'loading' — that can be found and fixed with targeted search-and-replace.

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

Forcing the object-based calling convention was the right call. The multiple-overload approach created a TypeScript inference problem: when the library needed to infer the type of the query function's return value, it had to handle three different signatures. This worked unreliably in practice, especially with complex generic types. The object form is unambiguous — queryFn is always in the same place, queryKey is always explicit — and TypeScript inference is predictably correct.

For developers worried about verbosity: the difference is adding queryKey: and queryFn: as keys inside an object, which most code editors fill in automatically with snippets or LSP autocomplete. The codemod handles the mechanical transformation across your existing codebase. For new code, the object form is actually cleaner to read because the query key and function are labeled rather than positional.

// ─── 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

The status/fetchStatus separation is worth understanding deeply because it's not just a naming change — it resolves a real source of bugs in v4. In v4, isLoading was true in two distinct situations: the initial load when no data existed yet, and any background refetch even when cached data was already available. This caused components that used isLoading to show loading spinners during background refreshes, covering the perfectly valid cached data with a skeleton or spinner. The correct v4 pattern to avoid this was status === 'loading' && !isFetching, which most developers didn't know about.

In v5, status === 'pending' means no data exists yet (show a skeleton), and fetchStatus === 'fetching' means a network request is in progress (optionally show a subtle refresh indicator). These two states are now orthogonal, and the common case — "show data if available, indicate background refresh separately" — becomes the obvious, natural pattern rather than requiring the workaround.

// ─── 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'] });
  },
});

Migrating Custom Hooks That Wrap useQuery

The codemod handles direct calls to useQuery, useMutation, and useInfiniteQuery, but most production codebases have wrapper hooks that provide domain-specific logic on top of these primitives. These require manual migration.

A typical v4 wrapper hook:

// v4 wrapper
function useUser(userId: string) {
  return useQuery(['user', userId], () => fetchUser(userId), {
    staleTime: 5 * 60 * 1000,
    enabled: !!userId,
  });
}

The v5 equivalent:

// v5 wrapper
function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,
    enabled: !!userId,
  });
}

The pattern is mechanical but requires finding every custom wrapper in the codebase. Run grep -r "useQuery(" src/ --include="*.ts" --include="*.tsx" and review the results — anything the codemod didn't automatically transform is a wrapper hook that needs manual updating.

The advantage of doing this work: wrapper hooks in v5 are the right place to define queryKey factories and reuse query options across different components. Centralizing queryKey: ['user', userId] in a hook means you can also reference it from queryClient.invalidateQueries in mutation callbacks without duplicating the key structure.

Suspense: First-Class with useSuspenseQuery

The dedicated useSuspenseQuery hook makes error handling cleaner as a side effect. Because it throws a Promise while loading (for Suspense to catch) and throws an error on failure (for ErrorBoundary to catch), your component code doesn't need to handle loading or error states at all. The component renders data, and the surrounding boundary infrastructure handles both conditions. This leads to significantly cleaner component code for data-heavy applications, where v4 components often had 5-10 lines of if (isLoading) / if (isError) guards before reaching the actual rendering logic.

useSuspenseQueries extends this to parallel queries: multiple independent data requirements in a single component all suspend together, resolved before the component renders. This is the clean equivalent of Promise.all for React components.

// ─── 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

Suspense and React 18: Why the Timing Was Right

The useSuspenseQuery hook in v5 addresses a real TypeScript limitation that existed in v4's suspense: true approach. In v4, adding suspense: true to a query option told React to suspend the component while data was loading, but TypeScript didn't know this. The type system still typed data as T | undefined, requiring optional chaining or type assertions even though at render time (when Suspense had resolved), data was guaranteed to be defined.

useSuspenseQuery solves this by design: the hook always returns { data: T } (never T | undefined). TypeScript knows that inside a useSuspenseQuery-consuming component, if execution reaches the JSX, the data is there. This single change eliminates a class of unnecessary null checks and makes components simpler and more readable.

The React 18 concurrent features context matters here too. React 18's Suspense is designed as a first-class rendering primitive, not the hack it was in earlier versions. startTransition, useTransition, and useDeferredValue all interact with Suspense boundaries. useSuspenseQuery integrates correctly with these primitives, enabling patterns like optimistic page transitions where the outgoing page stays visible while the incoming page's queries resolve.

The recommended pattern for new Next.js App Router projects is to use useSuspenseQuery for data dependencies that should block rendering, wrapped in React's Suspense and ErrorBoundary. The HydrationBoundary from TanStack Query v5 handles the SSR dehydration/hydration cycle, so prefetched queries arrive on the client pre-populated and there's no loading flash on initial page load.

Migrating from v3 Directly to v5

If your project is still on TanStack Query v3 (React Query v3), the recommendation is to migrate directly to v5 — skip v4 entirely. The changes between v3 and v4 were significant enough to require attention but didn't resolve the underlying API debt that v5 addresses. Running the v5 codemod on v3 code will handle a large portion of the differences, but you'll need to review the migration guide for v3-specific breaking changes (notably the queryKey format changes that happened in v3 to v4).

The time investment for v3 to v5 is roughly 50% more than v4 to v5, but you end up at the same destination without the intermediate step. Document the breaking changes you find in your codebase and create a PR for each logical batch of changes — this makes review easier and gives you natural checkpoints if the migration takes longer than expected.

For v2 users, the v5 changes are substantial enough that treating it as a partial rewrite of your data fetching layer is the right mental model. The core concepts map, but the API surface is different enough that a systematic file-by-file migration is more reliable than trying to run the codemod across deeply v2-style code.

TanStack Query vs. SWR in 2026

After v5, the choice between TanStack Query and SWR is primarily one of complexity vs. simplicity. SWR remains the best-fit option for applications with straightforward data fetching needs — fetch data on component mount, revalidate on focus, deduplicate concurrent requests. Its API surface is minimal and approachable.

TanStack Query v5 is the better choice when you need mutation management with cache invalidation, optimistic updates, background refetching with granular control, prefetching in server-side rendering contexts, infinite scrolling with useInfiniteQuery, or Suspense-based data loading. The v5 API improvements make these features more accessible than they were in v4, narrowing the "TanStack Query is complex" criticism that made some teams choose SWR.

For teams starting fresh in 2026, TanStack Query v5 is the stronger default because its feature set covers more ground as the application grows, and the migration cost from SWR to TanStack Query (if you eventually need its features) is higher than starting with TanStack Query from the beginning.

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

What Didn't Change (The Important Parts)

The core mental model of TanStack Query is identical in v5. Queries are identified by keys and associated with async functions that produce data. The cache is query-keyed and globally shared. Queries are "fresh" for their staleTime, then become "stale" and eligible for background refetching on next access. invalidateQueries marks entries stale immediately. Mutations trigger cache invalidation via onSuccess. The devtools provide a live view of the cache.

If you understand these concepts from v4, you understand v5. The breaking changes are all in the API surface — how you call the hooks and what the hooks return. The underlying behavior, caching semantics, and performance characteristics are the same. This is what makes the migration relatively contained: it's a syntax update more than a paradigm shift.

The ecosystem around TanStack Query also stayed stable through v5. The Next.js SSR patterns with dehydrate/HydrationBoundary, the React Native support, and the Vue/Svelte/Solid adapters all follow the same patterns with v5. If you've been writing guides, blog posts, or internal documentation about TanStack Query v4, the concepts transfer entirely — only specific API details need updating.

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 also: React vs Vue and React vs Svelte, Best GraphQL Clients for React in 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.