MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Query v5: New Features and Migration Guide

Master TanStack Query v5: simplified API, improved TypeScript inference, new mutation features, and step-by-step migration from v4.

React QueryTanStackReactData Fetching

By MinhVo

Introduction

TanStack Query v5 (formerly React Query) represents a major evolution in server state management for React applications. The release focuses on simplifying the API surface, improving TypeScript inference, and introducing features that make data fetching and caching more intuitive. If you're still on v4 or evaluating TanStack Query for the first time, this guide covers everything you need to know: the key API changes, new features, migration strategies, and production patterns that leverage v5's capabilities.

Server state management is one of the hardest problems in frontend development. Unlike client state, server state is asynchronous, shared between users, and can become stale at any moment. TanStack Query abstracts away the complexity of caching, background refetching, and synchronization, letting you focus on building features instead of managing fetch lifecycle.

Data fetching architecture

What's New in v5

Simplified API

The biggest change in v5 is the removal of the positional arguments pattern. In v4, options were spread across multiple arguments; in v5, everything goes into a single options object.

// v4 (positional arguments)
useQuery(['todos', { status: 'done' }], fetchTodos, {
  staleTime: 5000,
  refetchOnWindowFocus: false,
});
 
// v5 (single options object)
useQuery({
  queryKey: ['todos', { status: 'done' }],
  queryFn: fetchTodos,
  staleTime: 5000,
  refetchOnWindowFocus: false,
});

This change applies to all core hooks: useQuery, useInfiniteQuery, useMutation, useIsFetching, and useIsMutating. The unified API is easier to read, easier to type, and eliminates the confusion about which argument is which.

Improved TypeScript Inference

v5 leverages TypeScript's inference capabilities more aggressively. The queryFn return type now automatically flows through to data, eliminating the need for explicit type parameters in most cases.

// v4 — explicit type parameter needed
const { data } = useQuery<Todo[]>(['todos'], fetchTodos);
 
// v5 — type inferred from queryFn
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos, // fetchTodos returns Promise<Todo[]>
});
// data is automatically Todo[] | undefined

The select option also benefits from improved inference:

const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (data) => data.filter((todo) => todo.completed),
});
// data is Todo[] | undefined (inferred from select return type)

New useSuspenseQuery

v5 introduces useSuspenseQuery, which guarantees data is always defined by leveraging React Suspense:

import { useSuspenseQuery } from '@tanstack/react-query';
 
function TodoList() {
  const { data } = useSuspenseQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });
 
  // data is Todo[] (NOT undefined) — guaranteed by Suspense
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}
 
// Parent wraps with Suspense boundary
function App() {
  return (
    <Suspense fallback={<Loading />}>
      <TodoList />
    </Suspense>
  );
}

This eliminates the common if (!data) return <Loading /> pattern and integrates naturally with React's concurrent features.

useMutationState

Track all in-flight mutations with the new useMutationState hook:

import { useMutationState } from '@tanstack/react-query';
 
function PendingMutations() {
  const pendingMutations = useMutationState({
    filters: { status: 'pending' },
  });
 
  return (
    <div>
      {pendingMutations.map((mutation) => (
        <div key={mutation.mutationId}>
          Saving: {mutation.submittedAt?.toLocaleTimeString()}
        </div>
      ))}
    </div>
  );
}

prefetchQuery Returns Promise

In v5, prefetchQuery returns a promise that resolves when the query is fetched, making it easier to coordinate prefetching:

// In a route loader or server component
await queryClient.prefetchQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
});

Migration planning

Migration Guide: v4 to v5

Step 1: Update Packages

npm install @tanstack/react-query@5 @tanstack/react-query-devtools@5

Step 2: Codemod for API Changes

The TanStack team provides an automatic codemod:

npx @tanstack/query-codemods

This handles most of the positional argument migration automatically. However, manual review is recommended for complex cases.

Step 3: Update Hook Signatures

Replace all positional argument patterns:

// Before (v4)
useQuery(key, fetcher, options);
useInfiniteQuery(key, fetcher, options);
useMutation(mutationFn, options);
 
// After (v5)
useQuery({ queryKey, queryFn, ...options });
useInfiniteQuery({ queryKey, queryFn, ...options });
useMutation({ mutationFn, ...options });

Step 4: Update onSuccess/onError/onSettled

In v5, these callbacks are removed from useQuery options. Use useEffect instead:

// v4
useQuery(['todo', id], fetchTodo, {
  onSuccess: (data) => console.log('Fetched:', data),
  onError: (error) => console.error('Failed:', error),
});
 
// v5
const { data, error } = useQuery({
  queryKey: ['todo', id],
  queryFn: fetchTodo,
});
 
useEffect(() => {
  if (data) console.log('Fetched:', data);
}, [data]);
 
useEffect(() => {
  if (error) console.error('Failed:', error);
}, [error]);

The callbacks were removed because they created closure-related bugs and didn't compose well with React's concurrent features. The useEffect pattern is more explicit and avoids stale closure issues.

Step 5: Update Type Parameters

// v4 — four type parameters
useQuery<Todo[], Error, Todo[], string[]>(['todos'], fetchTodos);
 
// v5 — simplified, usually inferred
useQuery({
  queryKey: ['todos'] as const,
  queryFn: fetchTodos,
});

Step 6: Update QueryClient Defaults

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
});

Production Patterns

Optimistic Updates

function useUpdateTodo() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (updatedTodo: Todo) =>
      api.put(`/todos/${updatedTodo.id}`, updatedTodo),
 
    onMutate: async (updatedTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
 
      const previousTodos = queryClient.getQueryData(['todos']);
 
      queryClient.setQueryData(['todos'], (old: Todo[] | undefined) =>
        old?.map((todo) =>
          todo.id === updatedTodo.id ? updatedTodo : todo
        )
      );
 
      return { previousTodos };
    },
 
    onError: (_err, _updatedTodo, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },
 
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Dependent Queries

function UserWithPosts({ userId }: { userId: string }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
 
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!user, // Only fetch after user is loaded
  });
 
  return (
    <div>
      <h1>{user?.name}</h1>
      {posts?.map((post) => <article key={post.id}>{post.title}</article>)}
    </div>
  );
}

Infinite Queries

function PostFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
      initialPageParam: undefined as string | undefined,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    });
 
  return (
    <div>
      {data?.pages.flatMap((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Global Error Handling

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof NotFoundError) return false;
        return failureCount < 3;
      },
    },
    mutations: {
      onError: (error) => {
        toast.error(error.message);
      },
    },
  },
});
 
// Use QueryErrorResetBoundary for error boundaries
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <QueryErrorResetBoundary>
        {({ reset }) => (
          <ErrorBoundary
            onReset={reset}
            fallbackRender={({ error, resetErrorBoundary }) => (
              <div>
                <p>Error: {error.message}</p>
                <button onClick={resetErrorBoundary}>Retry</button>
              </div>
            )}
          >
            <AppRoutes />
          </ErrorBoundary>
        )}
      </QueryErrorResetBoundary>
    </QueryClientProvider>
  );
}

Performance monitoring

Performance Optimization

Window Focus Refetching

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  refetchOnWindowFocus: true, // Refetch when user returns to tab
  refetchInterval: 30000,     // Poll every 30 seconds
});

Query Invalidation Strategies

// Invalidate all queries matching a key
queryClient.invalidateQueries({ queryKey: ['todos'] });
 
// Invalidate exact match only
queryClient.invalidateQueries({
  queryKey: ['todos', { status: 'done' }],
  exact: true,
});
 
// Invalidate with predicate
queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'todos',
});

Garbage Collection

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  gcTime: 1000 * 60 * 10, // Keep in cache for 10 minutes after last observer
});

Comparison: v4 vs v5

Featurev4v5
API stylePositional argsOptions object
TypeScript inferenceManual type paramsAutomatic inference
Suspense supportExperimentaluseSuspenseQuery
onSuccess/onErrorIn query optionsUse useEffect
Mutation trackingManualuseMutationState
cacheTimeConfigurableRenamed to gcTime
keepPreviousDataOptionplaceholderData: keepPreviousData
CodemodN/AOfficial codemod

Best Practices

  1. Use the single options object — Consistent API reduces cognitive load
  2. Leverage TypeScript inference — Let queryFn types flow through
  3. Use useSuspenseQuery — Eliminate loading state boilerplate
  4. Prefetch for perceived performance — Use prefetchQuery in route loaders
  5. Invalidate precisely — Target specific queries instead of broad invalidation
  6. Set global defaults — Configure staleTime, retry, and gcTime in QueryClient

Common Pitfalls

PitfallImpactSolution
Using positional args after migrationRuntime errorsUse codemod, then manual review
Missing enabled guard on dependent queriesUnnecessary fetchesSet enabled: !!dependency
Not handling data as undefinedTypeScript errorsUse useSuspenseQuery or check isLoading
Over-fetching with broad invalidationPerformance issuesUse exact key matching
Stale closure in onSuccessBug: stale data in callbackUse useEffect instead

Query Invalidation

Query invalidation is a powerful pattern for keeping your data fresh after mutations. When a mutation succeeds, use queryClient.invalidateQueries to mark specific queries as stale, triggering a background refetch. Use query key prefixes to invalidate groups of related queries with a single call. For example, invalidating all queries that start with ['todos'] refreshes every query related to todos regardless of their specific parameters. Implement this pattern in your mutation onSuccess callbacks to ensure that the UI always displays the latest data after a write operation. This approach is simpler and more reliable than manually updating the cache after each mutation.

Dependent Queries

React Query supports dependent queries where one query depends on the results of another. Use the enabled option to conditionally fetch data based on the state of another query. For example, fetch a user's profile first, then use the user ID from the profile to fetch their posts. The second query remains disabled until the first query succeeds and provides the necessary data. This pattern prevents unnecessary requests and ensures that dependent data is fetched in the correct order. Implement dependent queries using the enabled option with a condition that checks whether the prerequisite data is available.

Prefetching Data

Prefetching improves the user experience by loading data before the user navigates to a new page or opens a new section. Use queryClient.prefetchQuery to load data into the cache before it is needed. Implement prefetching on hover or focus events so that data starts loading when the user indicates intent to navigate. Use the staleTime option to control how long prefetched data remains fresh in the cache. For infinite lists, prefetch the next page of data when the user is near the end of the current page. Prefetching reduces perceived loading times and creates a smoother user experience.

Error Handling Strategies

React Query provides flexible error handling at multiple levels. Handle errors at the query level using the onError callback or the error property returned by useQuery. Handle errors at the global level using the QueryCache onError option, which fires for any query error in the application. Implement retry logic using the retry and retryDelay options, which support both numeric values and callback functions for exponential backoff. Display error states in your UI using the error state from useQuery, and provide retry buttons that call the refetch function. These layered error handling strategies ensure that your application gracefully handles network failures and server errors.

Testing with React Query

Testing components that use React Query requires wrapping them with a QueryClientProvider. Create a test utility that provides a fresh QueryClient for each test to prevent state leakage between tests.

// test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react';
 
function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,        // Don't retry in tests
        gcTime: 0,           // Immediately garbage collect
        staleTime: 0,        // Always refetch
      },
    },
  });
}
 
function renderWithQuery(
  ui: React.ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  const queryClient = createTestQueryClient();
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
  return { ...render(ui, { wrapper, ...options }), queryClient };
}
 
// Example test
test('displays user data', async () => {
  server.use(
    http.get('/api/user', () => {
      return HttpResponse.json({ name: 'John', email: 'john@test.com' });
    })
  );
 
  renderWithQuery(<UserProfile />);
 
  expect(await screen.findByText('John')).toBeInTheDocument();
  expect(screen.getByText('john@test.com')).toBeInTheDocument();
});

Infinite Queries for Pagination

React Query's infinite query pattern handles paginated data elegantly. Use useInfiniteQuery to fetch pages of data with automatic page management and prefetching.

import { useInfiniteQuery } from '@tanstack/react-query';
 
function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/posts?cursor=${pageParam}&limit=20`).then(r => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  });
 
  if (status === 'pending') return <Spinner />;
 
  return (
    <div>
      {data.pages.map((page) =>
        page.posts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))
      )}
      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load more'}
        </button>
      )}
    </div>
  );
}

Optimistic Updates

Implement optimistic updates to make mutations feel instant. Update the cache before the server responds, then roll back if the mutation fails.

import { useMutation, useQueryClient } from '@tanstack/react-query';
 
function useAddTodo() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (newTodo: { title: string }) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(r => r.json()),
 
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData(['todos']);
      queryClient.setQueryData(['todos'], (old: Todo[]) => [
        ...old,
        { id: Date.now(), title: newTodo.title, completed: false },
      ]);
      return { previous };
    },
 
    onError: (_err, _newTodo, context) => {
      queryClient.setQueryData(['todos'], context?.previous);
    },
 
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

React Query continues to be the most popular data fetching library in the React ecosystem with over forty million weekly downloads on npm.

The simplified API in TanStack Query v5 reduces the learning curve while providing more powerful defaults for common data fetching patterns.

The simplified API in TanStack Query v5 reduces the learning curve while providing more powerful defaults for common data fetching patterns, making it easier for teams to adopt consistent server state management.

React Query Devtools

The React Query Devtools provide invaluable insight into your application's cache state during development. They show all active queries, their status, last update time, and the cached data. Install the devtools package and add it to your application root to visualize cache behavior and identify queries that are stale, fetching, or in error state. The devtools panel floats above your application and can be minimized, expanded, or moved to any corner of the screen. Use the devtools to verify that your query keys are correct, that cache invalidation is working as expected, and that background refetching occurs at the intervals you configured.

Optimistic Updates with Rollback

Optimistic updates immediately reflect mutations in the UI before the server confirms the change, creating a perceived instant response. In v5, implementing optimistic updates follows a consistent pattern within the onMutate callback. Cancel any outgoing refetches to prevent overwriting the optimistic update, snapshot the previous data for rollback, then update the cache with the optimistic value. If the mutation fails, the onError callback rolls back to the snapshot. Always refetch in onSettled regardless of success or failure to ensure the cache matches the server state.

useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) => (todo.id === newTodo.id ? { ...todo, ...newTodo } : todo))
    );
    return { previous };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Infinite Queries for Pagination

The useInfiniteQuery hook manages paginated data by tracking page parameters and fetching subsequent pages on demand. In v5, the initialPageParam option is now required, and the getNextPageParam and getPreviousPageParam functions receive the full query context including pageParam. This change makes cursor-based and offset-based pagination more explicit and type-safe.

Combine useInfiniteQuery with virtual scrolling libraries like TanStack Virtual to render thousands of items without performance degradation. The virtual list renders only the visible items while useInfiniteQuery prefetches the next page when the user scrolls near the bottom. Configure maxPages in the query options to limit how many pages are kept in memory, preventing unbounded cache growth as users scroll through large datasets.

Conclusion

TanStack Query v5 is a significant improvement that simplifies the API, improves TypeScript support, and integrates better with React's concurrent features. The migration is largely mechanical thanks to the official codemod, and the benefits are substantial: cleaner code, better type safety, and new patterns like useSuspenseQuery that reduce boilerplate.

Key takeaways:

  1. Single options object replaces positional arguments across all hooks
  2. TypeScript inference eliminates most explicit type parameters
  3. useSuspenseQuery guarantees data is defined with Suspense
  4. onSuccess/onError removed from useQuery — use useEffect instead
  5. Official codemod automates most migration work
  6. cacheTime renamed to gcTime for clarity