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.
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[] | undefinedThe 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 Guide: v4 to v5
Step 1: Update Packages
npm install @tanstack/react-query@5 @tanstack/react-query-devtools@5Step 2: Codemod for API Changes
The TanStack team provides an automatic codemod:
npx @tanstack/query-codemodsThis 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 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
| Feature | v4 | v5 |
|---|---|---|
| API style | Positional args | Options object |
| TypeScript inference | Manual type params | Automatic inference |
| Suspense support | Experimental | useSuspenseQuery |
onSuccess/onError | In query options | Use useEffect |
| Mutation tracking | Manual | useMutationState |
cacheTime | Configurable | Renamed to gcTime |
keepPreviousData | Option | placeholderData: keepPreviousData |
| Codemod | N/A | Official codemod |
Best Practices
- Use the single options object — Consistent API reduces cognitive load
- Leverage TypeScript inference — Let
queryFntypes flow through - Use
useSuspenseQuery— Eliminate loading state boilerplate - Prefetch for perceived performance — Use
prefetchQueryin route loaders - Invalidate precisely — Target specific queries instead of broad invalidation
- Set global defaults — Configure
staleTime,retry, andgcTimeinQueryClient
Common Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Using positional args after migration | Runtime errors | Use codemod, then manual review |
Missing enabled guard on dependent queries | Unnecessary fetches | Set enabled: !!dependency |
Not handling data as undefined | TypeScript errors | Use useSuspenseQuery or check isLoading |
| Over-fetching with broad invalidation | Performance issues | Use exact key matching |
Stale closure in onSuccess | Bug: stale data in callback | Use 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:
- Single options object replaces positional arguments across all hooks
- TypeScript inference eliminates most explicit type parameters
useSuspenseQueryguaranteesdatais defined with SuspenseonSuccess/onErrorremoved fromuseQuery— useuseEffectinstead- Official codemod automates most migration work
cacheTimerenamed togcTimefor clarity