The complete guide to building production-ready React apps with TanStack Query v5, queryOptions, and best practices
π Table of Contents
- Why TanStack Query v5?
- Prerequisites
- Project Structure
- Quick Start
- Core Concepts
- queryOptions Pattern
- Queries Deep Dive
- Mutations & Optimistic Updates
- Advanced Patterns
- Performance Optimization
- Error Handling
- Testing
- Best Practices
- Migration from v4
- Troubleshooting
- Resources
π Why TanStack Query v5?
The Problem It Solves
Traditional state management (Redux, Context API) treats server data like client state. This leads to:
- Manual cache management - Writing cache update logic
- Loading state hell - Managing loading/error states everywhere
- Stale data - No automatic background refetching
- Boilerplate overload - Hundreds of lines for simple data fetching
TanStack Query's Solution
// Without TanStack Query: ~50 lines of code
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
// With TanStack Query: ~5 lines
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});
What's New in v5?
- 20% smaller bundle size than v4
-
queryOptionshelper - Type-safe query definitions -
Suspense is stable -
useSuspenseQueryhook - Simplified optimistic updates - No manual cache writing
- Better TypeScript support - Improved type inference
-
New
isPendingstate - Clearer loading states -
useMutationState- Access all mutation states globally -
Infinite query improvements -
maxPagesoption
β Prerequisites
Before diving in, ensure you have:
- Node.js (v18+)
- React (v18+) - TanStack Query v5 requires React 18
- TypeScript (v4.7+) - Recommended but optional
- Basic understanding of:
- React Hooks (useState, useEffect)
- Async/Await and Promises
- REST APIs
Quick Environment Check
node --version # Should be v18+
npm --version # Should be 8+
π Project Structure
tanstack-query-app/
βββ src/
β βββ api/ # API layer
β β βββ axios-instance.ts # Axios/Fetch setup
β β βββ endpoints.ts # API endpoint definitions
β β βββ types.ts # API response types
β βββ queries/ # Query definitions (THE PATTERN)
β β βββ users.queries.ts # User-related queries
β β βββ posts.queries.ts # Post-related queries
β β βββ index.ts # Export all queries
β βββ hooks/ # Custom hooks
β β βββ useUsers.ts # User data hooks
β β βββ usePosts.ts # Post data hooks
β β βββ useAuth.ts # Auth hooks
β βββ components/ # React components
β β βββ users/
β β β βββ UserList.tsx
β β β βββ UserDetail.tsx
β β β βββ CreateUser.tsx
β β βββ posts/
β β βββ PostList.tsx
β β βββ CreatePost.tsx
β βββ lib/ # Utilities
β β βββ query-client.ts # QueryClient configuration
β β βββ utils.ts # Helper functions
β βββ types/ # TypeScript types
β β βββ user.types.ts
β β βββ post.types.ts
β βββ App.tsx # Root component
β βββ main.tsx # Entry point
βββ .env # Environment variables
βββ package.json
βββ tsconfig.json
βββ vite.config.ts
β‘ Quick Start
1. Create React Project
# Using Vite (recommended)
npm create vite@latest tanstack-query-app -- --template react-ts
cd tanstack-query-app
# Or using Create React App
npx create-react-app tanstack-query-app --template typescript
cd tanstack-query-app
2. Install Dependencies
# TanStack Query
npm install @tanstack/react-query
# DevTools (optional but highly recommended)
npm install @tanstack/react-query-devtools
# Axios for API calls
npm install axios
# Additional utilities
npm install react-router-dom
3. Setup QueryClient
Create src/lib/query-client.ts:
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 3,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
mutations: {
retry: 1,
},
},
});
4. Wrap App with Provider
Update src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/query-client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);
5. Create Your First Query
Create src/queries/users.queries.ts:
import { queryOptions } from '@tanstack/react-query';
import axios from 'axios';
export interface User {
id: number;
name: string;
email: string;
username: string;
}
// API functions
const fetchUsers = async (): Promise<User[]> => {
const { data } = await axios.get('https://jsonplaceholder.typicode.com/users');
return data;
};
const fetchUserById = async (id: number): Promise<User> => {
const { data } = await axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);
return data;
};
// Query options using the queryOptions helper
export const usersQueryOptions = () =>
queryOptions({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5 minutes
});
export const userQueryOptions = (id: number) =>
queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUserById(id),
staleTime: 1000 * 60 * 5,
});
6. Use in Component
Create src/components/users/UserList.tsx:
import { useQuery } from '@tanstack/react-query';
import { usersQueryOptions } from '../../queries/users.queries';
export function UserList() {
const { data: users, isLoading, error } = useQuery(usersQueryOptions());
if (isLoading) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Users</h2>
<ul>
{users?.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}
7. Run the App
npm run dev
Visit http://localhost:5173 and see TanStack Query in action! π
π§ Core Concepts
Query Keys
Query keys uniquely identify queries. They're arrays that can contain:
// Simple key
['users']
// With parameters
['users', userId]
// With filters
['users', { status: 'active', role: 'admin' }]
// Hierarchical
['projects', projectId, 'tasks', { completed: false }]
Rules:
- Keys are deterministic (same key = same query)
- Order matters:
['users', 1]β['users', '1'] - Objects are compared deeply
- Keys should be serializable
Query States
TanStack Query v5 has clear states:
{
isPending: boolean, // No data yet (replaces isLoading in v4)
isLoading: boolean, // isPending && isFetching (first load only)
isError: boolean, // Query failed
isSuccess: boolean, // Query succeeded
isFetching: boolean, // Currently fetching (background or initial)
isRefetching: boolean, // Background refetch
isStale: boolean, // Data is stale (needs refetch)
}
Key difference in v5:
-
isPending- No cached data exists yet -
isLoading- First-time fetch (isPending + isFetching) -
isFetching- Any fetch (initial or background)
Stale Time vs GC Time
{
staleTime: 1000 * 60 * 5, // 5 minutes - how long data is "fresh"
gcTime: 1000 * 60 * 10, // 10 minutes - how long unused data stays in cache
}
Mental model:
- Fetch β Data is fresh (staleTime countdown starts)
- After staleTime β Data becomes stale (still in cache, shows immediately, but refetches in background)
- After gcTime of no usage β Data is garbage collected (removed from cache)
Fetch β [Fresh Period] β [Stale Period] β [GC Period] β Deleted
βstaleTime βgcTime
π― queryOptions Pattern
Why queryOptions?
The queryOptions helper is THE recommended pattern in v5. It provides:
β
Type safety - Full TypeScript inference
β
Reusability - Share queries across components
β
Co-location - Keys and functions together
β
Prefetching - Use with prefetchQuery
β
Type-safe cache access - getQueryData knows the type
Basic Pattern
import { queryOptions } from '@tanstack/react-query';
// Define query options
export const todoQueryOptions = (id: number) =>
queryOptions({
queryKey: ['todos', id],
queryFn: async () => {
const res = await fetch(`/api/todos/${id}`);
if (!res.ok) throw new Error('Failed to fetch todo');
return res.json() as Promise<Todo>;
},
staleTime: 1000 * 60,
});
// Use in component
function TodoDetail({ id }: { id: number }) {
const { data } = useQuery(todoQueryOptions(id));
return <div>{data?.title}</div>;
}
// Use in prefetch
queryClient.prefetchQuery(todoQueryOptions(5));
// Type-safe cache access
const todo = queryClient.getQueryData(todoQueryOptions(5).queryKey);
// ^? Todo | undefined (TypeScript knows the type!)
Complete Example: Posts API
Create src/api/axios-instance.ts:
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor (add auth tokens, etc.)
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
// Response interceptor (handle errors globally)
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Handle unauthorized
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Create src/types/post.types.ts:
export interface Post {
id: number;
userId: number;
title: string;
body: string;
}
export interface CreatePostDto {
userId: number;
title: string;
body: string;
}
export interface UpdatePostDto {
title?: string;
body?: string;
}
export interface PostFilters {
userId?: number;
search?: string;
}
Create src/queries/posts.queries.ts:
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query';
import { api } from '../api/axios-instance';
import type { Post, PostFilters } from '../types/post.types';
// ============================================
// API Functions
// ============================================
const fetchPosts = async (filters?: PostFilters): Promise<Post[]> => {
const { data } = await api.get<Post[]>('/posts', { params: filters });
return data;
};
const fetchPostById = async (id: number): Promise<Post> => {
const { data } = await api.get<Post>(`/posts/${id}`);
return data;
};
const fetchPostsByUser = async (userId: number): Promise<Post[]> => {
const { data } = await api.get<Post[]>(`/posts?userId=${userId}`);
return data;
};
// ============================================
// Query Options (THE PATTERN)
// ============================================
/**
* Fetch all posts with optional filters
*/
export const postsQueryOptions = (filters?: PostFilters) =>
queryOptions({
queryKey: ['posts', filters] as const,
queryFn: () => fetchPosts(filters),
staleTime: 1000 * 60 * 5, // 5 minutes
});
/**
* Fetch a single post by ID
*/
export const postQueryOptions = (id: number) =>
queryOptions({
queryKey: ['posts', id] as const,
queryFn: () => fetchPostById(id),
staleTime: 1000 * 60 * 5,
// Only fetch if id is valid
enabled: id > 0,
});
/**
* Fetch posts by user ID
*/
export const userPostsQueryOptions = (userId: number) =>
queryOptions({
queryKey: ['posts', 'user', userId] as const,
queryFn: () => fetchPostsByUser(userId),
staleTime: 1000 * 60 * 3,
});
/**
* Infinite query for paginated posts
*/
export const postsInfiniteQueryOptions = () =>
infiniteQueryOptions({
queryKey: ['posts', 'infinite'] as const,
queryFn: async ({ pageParam }) => {
const { data } = await api.get<Post[]>('/posts', {
params: { _page: pageParam, _limit: 10 },
});
return data;
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.length === 10 ? allPages.length + 1 : undefined;
},
staleTime: 1000 * 60 * 5,
// NEW in v5: Limit pages in cache
maxPages: 3,
});
Using Query Options in Components
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { postsQueryOptions, postQueryOptions } from '../queries/posts.queries';
// Regular query
function PostList() {
const { data: posts, isLoading } = useQuery(postsQueryOptions());
if (isLoading) return <div>Loading...</div>;
return <div>{posts?.map(post => <PostCard key={post.id} post={post} />)}</div>;
}
// With filters
function FilteredPostList({ userId }: { userId: number }) {
const { data: posts } = useQuery(postsQueryOptions({ userId }));
return <div>{posts?.map(post => <PostCard key={post.id} post={post} />)}</div>;
}
// Suspense query (v5 stable feature)
function PostDetail({ id }: { id: number }) {
const { data: post } = useSuspenseQuery(postQueryOptions(id));
// data is ALWAYS defined (never undefined) with useSuspenseQuery
return (
<div>
<h1>{post.title}</h1>
<p>{post.body}</p>
</div>
);
}
// Wrap in Suspense boundary
function PostDetailPage({ id }: { id: number }) {
return (
<Suspense fallback={<PostSkeleton />}>
<PostDetail id={id} />
</Suspense>
);
}
π Queries Deep Dive
Dependent Queries
Queries that depend on data from other queries:
function UserPosts({ email }: { email: string }) {
// First query: Get user by email
const { data: user } = useQuery({
queryKey: ['users', 'by-email', email],
queryFn: () => fetchUserByEmail(email),
});
// Second query: Get posts (only runs when user exists)
const { data: posts } = useQuery({
queryKey: ['posts', 'user', user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user?.id, // Only run if user.id exists
});
return <PostList posts={posts} />;
}
Parallel Queries
Fetch multiple queries simultaneously:
function Dashboard() {
// All queries run in parallel
const users = useQuery(usersQueryOptions());
const posts = useQuery(postsQueryOptions());
const comments = useQuery(commentsQueryOptions());
if (users.isLoading || posts.isLoading || comments.isLoading) {
return <Spinner />;
}
return (
<div>
<Stats users={users.data} posts={posts.data} comments={comments.data} />
</div>
);
}
useQueries for Dynamic Lists
When you need to fetch variable number of queries:
import { useQueries } from '@tanstack/react-query';
function MultipleUsers({ userIds }: { userIds: number[] }) {
const userQueries = useQueries({
queries: userIds.map(id => userQueryOptions(id)),
// NEW in v5: Combine results
combine: results => ({
data: results.map(r => r.data),
isPending: results.some(r => r.isPending),
}),
});
return (
<div>
{userQueries.data.map((user, idx) => (
<UserCard key={userIds[idx]} user={user} />
))}
</div>
);
}
Prefetching
Prefetch data before it's needed:
import { useQueryClient } from '@tanstack/react-query';
function PostPreview({ postId }: { postId: number }) {
const queryClient = useQueryClient();
// Prefetch on hover
const handleMouseEnter = () => {
queryClient.prefetchQuery(postQueryOptions(postId));
};
return (
<Link to={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
View Post
</Link>
);
}
// Prefetch in route loader (React Router)
export const postDetailLoader = async ({ params }: LoaderFunctionArgs) => {
const postId = Number(params.id);
await queryClient.prefetchQuery(postQueryOptions(postId));
return null;
};
Initial Data
Provide initial data to avoid loading states:
function PostDetail({ postId }: { postId: number }) {
const queryClient = useQueryClient();
const { data: post } = useQuery({
...postQueryOptions(postId),
// Use data from posts list if available
initialData: () => {
const posts = queryClient.getQueryData(postsQueryOptions().queryKey);
return posts?.find(p => p.id === postId);
},
// Only use initial data if it's fresh
initialDataUpdatedAt: () =>
queryClient.getQueryState(postsQueryOptions().queryKey)?.dataUpdatedAt,
});
return <PostCard post={post} />;
}
Placeholder Data
Show placeholder while loading (NEW behavior in v5):
function PostList({ userId }: { userId: number }) {
const { data: posts, isPlaceholderData } = useQuery({
...postsQueryOptions({ userId }),
// NEW in v5: keepPreviousData is now placeholderData
placeholderData: previousData => previousData,
});
return (
<div>
{/* Show visual indicator during background fetch */}
{isPlaceholderData && <div className="opacity-50">Updating...</div>}
<PostList posts={posts} />
</div>
);
}
// Built-in helper (v5)
import { keepPreviousData } from '@tanstack/react-query';
const { data } = useQuery({
queryKey: ['posts', userId],
queryFn: fetchPosts,
placeholderData: keepPreviousData, // Same as above
});
βοΈ Mutations & Optimistic Updates
Basic Mutation
Create src/queries/posts.mutations.ts:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/axios-instance';
import type { Post, CreatePostDto } from '../types/post.types';
import { postsQueryOptions } from './posts.queries';
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: CreatePostDto): Promise<Post> => {
const { data } = await api.post<Post>('/posts', newPost);
return data;
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
export function useUpdatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: { id: number } & Partial<Post>) => {
const { data } = await api.patch<Post>(`/posts/${id}`, updates);
return data;
},
onSuccess: (updatedPost) => {
// Update single item in cache
queryClient.setQueryData(
postQueryOptions(updatedPost.id).queryKey,
updatedPost
);
// Invalidate list
queryClient.invalidateQueries({ queryKey: ['posts'], exact: false });
},
});
}
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await api.delete(`/posts/${id}`);
return id;
},
onSuccess: (deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ['posts', deletedId] });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: ['posts'], exact: false });
},
});
}
Using Mutations in Components
import { useCreatePost, useUpdatePost } from '../queries/posts.mutations';
function CreatePostForm() {
const createPost = useCreatePost();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost.mutate(
{
userId: 1,
title: formData.get('title') as string,
body: formData.get('body') as string,
},
{
onSuccess: () => {
alert('Post created!');
e.currentTarget.reset();
},
onError: (error) => {
alert(`Error: ${error.message}`);
},
}
);
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Body" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
{createPost.isError && <p>Error: {createPost.error.message}</p>}
</form>
);
}
Optimistic Updates (Simplified in v5!)
TanStack Query v5 makes optimistic updates easier by using mutation variables:
export function useTogglePostLike() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (postId: number) => {
const { data } = await api.post(`/posts/${postId}/like`);
return data;
},
// NEW v5 pattern: Use variables for optimistic UI
onMutate: async (postId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// Snapshot previous value
const previousPost = queryClient.getQueryData(
postQueryOptions(postId).queryKey
);
// Optimistically update
queryClient.setQueryData(postQueryOptions(postId).queryKey, (old: Post) => ({
...old,
likes: old.likes + 1,
}));
// Return context with previous value
return { previousPost };
},
onError: (err, postId, context) => {
// Rollback on error
queryClient.setQueryData(
postQueryOptions(postId).queryKey,
context?.previousPost
);
},
onSettled: (data, error, postId) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
}
// Usage with optimistic UI feedback
function LikeButton({ postId }: { postId: number }) {
const toggleLike = useTogglePostLike();
return (
<button
onClick={() => toggleLike.mutate(postId)}
disabled={toggleLike.isPending}
>
π Like {toggleLike.variables === postId && '(saving...)'}
</button>
);
}
Advanced: Optimistic Updates with Lists
export function useCreatePostOptimistic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: CreatePostDto): Promise<Post> => {
const { data } = await api.post('/posts', newPost);
return data;
},
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData(postsQueryOptions().queryKey);
// Optimistic post with temporary ID
const optimisticPost: Post = {
id: Date.now(), // Temporary ID
...newPost,
};
// Add to list
queryClient.setQueryData(postsQueryOptions().queryKey, (old: Post[] = []) => [
optimisticPost,
...old,
]);
return { previousPosts };
},
onError: (err, newPost, context) => {
queryClient.setQueryData(postsQueryOptions().queryKey, context?.previousPosts);
},
onSuccess: (createdPost) => {
// Replace optimistic post with real one
queryClient.setQueryData(postsQueryOptions().queryKey, (old: Post[] = []) =>
old.map(post => (post.id === createdPost.id ? createdPost : post))
);
},
});
}
useMutationState (NEW in v5!)
Access mutation state globally:
import { useMutationState } from '@tanstack/react-query';
function GlobalLoadingIndicator() {
// Get all pending mutations
const pendingMutations = useMutationState({
filters: { status: 'pending' },
select: mutation => ({
mutationKey: mutation.state.mutationKey,
variables: mutation.state.variables,
}),
});
if (pendingMutations.length === 0) return null;
return (
<div className="fixed top-0 right-0 p-4">
{pendingMutations.map((m, i) => (
<div key={i}>Saving {m.mutationKey}...</div>
))}
</div>
);
}
// Check if specific mutation is pending
function useIsCreatingPost() {
const mutations = useMutationState({
filters: { mutationKey: ['posts', 'create'], status: 'pending' },
});
return mutations.length > 0;
}
π Advanced Patterns
Infinite Queries
Pagination made easy:
import { useInfiniteQuery } from '@tanstack/react-query';
import { postsInfiniteQueryOptions } from '../queries/posts.queries';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery(postsInfiniteQueryOptions());
if (isLoading) return <Spinner />;
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}
// With intersection observer
import { useInView } from 'react-intersection-observer';
function AutoLoadInfiniteList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
postsInfiniteQueryOptions()
);
// Auto-fetch when sentinel is in view
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map(page =>
page.map(post => <PostCard key={post.id} post={post} />)
)}
{/* Sentinel element */}
<div ref={ref}>{isFetchingNextPage && <Spinner />}</div>
</div>
);
}
Query Cancellation
Cancel queries when component unmounts:
import { useQuery } from '@tanstack/react-query';
const fetchPosts = async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch('/api/posts', { signal });
return res.json();
};
export const postsQueryOptions = () =>
queryOptions({
queryKey: ['posts'],
queryFn: ({ signal }) => fetchPosts({ signal }),
});
// Query auto-cancels when component unmounts
function PostList() {
const { data } = useQuery(postsQueryOptions());
return <div>{/* ... */}</div>;
}
Retry Logic
Smart retry strategies:
export const postsQueryOptions = () =>
queryOptions({
queryKey: ['posts'],
queryFn: fetchPosts,
retry: (failureCount, error) => {
// Don't retry on 404
if (error.response?.status === 404) return false;
// Retry up to 3 times for other errors
return failureCount < 3;
},
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});
Polling
Auto-refetch at intervals:
function LiveDashboard() {
const { data: stats } = useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: fetchStats,
refetchInterval: 5000, // Refetch every 5 seconds
refetchIntervalInBackground: true, // Continue when tab is inactive
});
return <StatsDisplay stats={stats} />;
}
// Conditional polling
function ConditionalPolling() {
const [isLive, setIsLive] = useState(false);
const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
refetchInterval: isLive ? 1000 : false, // Only poll when live mode is on
});
return (
<div>
<button onClick={() => setIsLive(!isLive)}>
{isLive ? 'Stop' : 'Start'} Live Updates
</button>
<DataDisplay data={data} />
</div>
);
}
β‘ Performance Optimization
1. Selective Re-renders with select
Transform data to prevent unnecessary re-renders:
// Only re-render when user name changes
function UserName({ userId }: { userId: number }) {
const { data: name } = useQuery({
...userQueryOptions(userId),
select: user => user.name, // Only subscribe to name changes
});
return <div>{name}</div>;
}
// Extract specific fields
function PostTitles() {
const { data: titles } = useQuery({
...postsQueryOptions(),
select: posts => posts.map(p => p.title),
});
return <ul>{titles?.map(title => <li key={title}>{title}</li>)}</ul>;
}
2. Structural Sharing
TanStack Query automatically prevents re-renders if data shape doesn't change:
// Even if refetch runs, component won't re-render if data is identical
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
refetchInterval: 1000, // Refetch every second
// But only re-render when data actually changes
});
3. Query Splitting
Split large queries into smaller, focused ones:
// β Bad: Fetch everything
const { data: user } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUserWithEverything(id), // Posts, comments, profile, settings...
});
// β
Good: Split into focused queries
const { data: user } = useQuery(userQueryOptions(id));
const { data: posts } = useQuery(userPostsQueryOptions(id));
const { data: settings } = useQuery(userSettingsQueryOptions(id));
4. Prefetching Strategies
// Prefetch on hover
function PostLink({ postId }: { postId: number }) {
const queryClient = useQueryClient();
return (
<Link
to={`/posts/${postId}`}
onMouseEnter={() => queryClient.prefetchQuery(postQueryOptions(postId))}
>
View Post
</Link>
);
}
// Prefetch next page in infinite query
function InfiniteList() {
const { data, hasNextPage, fetchNextPage } = useInfiniteQuery(
postsInfiniteQueryOptions()
);
// Prefetch next page when user scrolls to 80%
useEffect(() => {
if (hasNextPage) {
const handleScroll = () => {
const scrollPercentage =
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
if (scrollPercentage > 80) {
fetchNextPage();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}
}, [hasNextPage, fetchNextPage]);
return <div>{/* ... */}</div>;
}
5. Disable Queries When Not Needed
function UserProfile({ userId }: { userId?: number }) {
const { data: user } = useQuery({
...userQueryOptions(userId!),
enabled: !!userId, // Don't fetch if userId is undefined
});
if (!userId) return <div>No user selected</div>;
if (!user) return <div>Loading...</div>;
return <UserCard user={user} />;
}
π‘οΈ Error Handling
Component-Level Error Handling
function PostList() {
const { data, error, isError, refetch } = useQuery(postsQueryOptions());
if (isError) {
return (
<div className="error">
<p>Failed to load posts: {error.message}</p>
<button onClick={() => refetch()}>Try Again</button>
</div>
);
}
return <div>{/* ... */}</div>;
}
Global Error Handling
Create src/lib/query-client.ts:
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
toast.error(`Query error: ${error.message}`);
},
},
mutations: {
onError: (error) => {
toast.error(`Mutation error: ${error.message}`);
},
},
},
});
Error Boundaries (Recommended Pattern)
Create src/components/ErrorBoundary.tsx:
import { Component, ReactNode } from 'react';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
interface Props {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
}
class ErrorBoundaryClass extends Component<Props, { hasError: boolean; error?: Error }> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
this.props.fallback?.(this.state.error!, () =>
this.setState({ hasError: false })
) || <div>Something went wrong</div>
);
}
return this.props.children;
}
}
export function ErrorBoundary({ children, fallback }: Props) {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundaryClass
fallback={(error, resetError) => {
return (
fallback?.(error, () => {
reset();
resetError();
}) || (
<div>
<p>Error: {error.message}</p>
<button
onClick={() => {
reset();
resetError();
}}
>
Try again
</button>
</div>
)
);
}}
>
{children}
</ErrorBoundaryClass>
);
}
Usage:
function App() {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<Posts />
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
}
Typed Error Handling
import axios, { AxiosError } from 'axios';
interface ApiError {
message: string;
code: string;
statusCode: number;
}
export const postsQueryOptions = () =>
queryOptions<Post[], AxiosError<ApiError>>({
queryKey: ['posts'],
queryFn: async () => {
const { data } = await api.get<Post[]>('/posts');
return data;
},
});
// Usage with typed error
function PostList() {
const { data, error } = useQuery(postsQueryOptions());
if (error) {
// TypeScript knows error is AxiosError<ApiError>
return <div>Error {error.response?.data.code}: {error.response?.data.message}</div>;
}
return <div>{/* ... */}</div>;
}
π§ͺ Testing
Setup Testing Utilities
Create src/test/utils.tsx:
import { ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
});
}
interface WrapperProps {
children: ReactNode;
}
export function createWrapper() {
const testQueryClient = createTestQueryClient();
return ({ children }: WrapperProps) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
}
export function renderWithClient(
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
const testQueryClient = createTestQueryClient();
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
),
...options,
});
}
Testing Queries
import { renderHook, waitFor } from '@testing-library/react';
import { createWrapper } from './test/utils';
import { useQuery } from '@tanstack/react-query';
import { postsQueryOptions } from './queries/posts.queries';
describe('Posts Query', () => {
it('should fetch posts successfully', async () => {
const { result } = renderHook(() => useQuery(postsQueryOptions()), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(100);
expect(result.current.data[0]).toHaveProperty('title');
});
it('should handle errors', async () => {
// Mock API error
vi.spyOn(api, 'get').mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useQuery(postsQueryOptions()), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Network error');
});
});
Testing Mutations
import { renderHook, waitFor } from '@testing-library/react';
import { useCreatePost } from './queries/posts.mutations';
import { createWrapper } from './test/utils';
describe('Create Post Mutation', () => {
it('should create a post', async () => {
const { result } = renderHook(() => useCreatePost(), {
wrapper: createWrapper(),
});
result.current.mutate({
userId: 1,
title: 'Test Post',
body: 'Test Body',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
userId: 1,
title: 'Test Post',
body: 'Test Body',
});
});
});
Testing Components
import { screen } from '@testing-library/react';
import { renderWithClient } from './test/utils';
import { PostList } from './components/PostList';
import { server } from './mocks/server';
import { rest } from 'msw';
describe('PostList Component', () => {
it('should display posts', async () => {
renderWithClient(<PostList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Post Title 1')).toBeInTheDocument();
});
});
it('should handle errors', async () => {
server.use(
rest.get('/posts', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server Error' }));
})
);
renderWithClient(<PostList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
π Best Practices
1. Use queryOptions Pattern
β
DO THIS:
// queries/users.queries.ts
export const userQueryOptions = (id: number) =>
queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
});
// Component
const { data } = useQuery(userQueryOptions(5));
// Prefetch
queryClient.prefetchQuery(userQueryOptions(5));
β NOT THIS:
// Component
const { data } = useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
});
// Different place - keys might drift!
queryClient.prefetchQuery({
queryKey: ['user', id], // Typo! Different key
queryFn: () => fetchUser(id),
});
2. Structure Query Keys Hierarchically
// β
Good: Hierarchical, predictable
['posts'] // All posts
['posts', { status: 'published' }] // Filtered posts
['posts', postId] // Single post
['posts', postId, 'comments'] // Post comments
['posts', postId, 'comments', commentId] // Single comment
// β Bad: Flat, unpredictable
['allPosts']
['publishedPosts']
['post-123']
['commentsForPost-123']
3. Co-locate Query Definitions
src/
queries/ β All query definitions here
users.queries.ts
posts.queries.ts
users.mutations.ts
posts.mutations.ts
4. Set Appropriate Stale Times
// Fast-changing data (stock prices, live scores)
staleTime: 0 // Always stale, always refetch
// Moderate (user feed, notifications)
staleTime: 1000 * 60 // 1 minute
// Slow-changing (user profile, settings)
staleTime: 1000 * 60 * 5 // 5 minutes
// Rarely changes (app config, translations)
staleTime: 1000 * 60 * 60 // 1 hour or Infinity
5. Handle Loading States Properly
// β
Good: Different states for different scenarios
function PostList() {
const { data, isPending, isError, isFetching } = useQuery(postsQueryOptions());
if (isPending) return <Skeleton />; // First load
if (isError) return <Error />;
return (
<div>
{isFetching && <RefreshIndicator />} {/* Background refetch */}
<Posts posts={data} />
</div>
);
}
6. Use Suspense for Cleaner Code
// β
Cleaner with Suspense
function PostDetail({ id }: { id: number }) {
const { data: post } = useSuspenseQuery(postQueryOptions(id));
// data is NEVER undefined!
return <PostCard post={post} />;
}
function Page() {
return (
<Suspense fallback={<Skeleton />}>
<PostDetail id={5} />
</Suspense>
);
}
7. Invalidate Strategically
// β
Invalidate related queries
mutation.onSuccess = () => {
// Invalidate all posts queries
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Invalidate specific post
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
// Invalidate with filters
queryClient.invalidateQueries({
queryKey: ['posts'],
predicate: query => query.state.data?.userId === userId,
});
};
8. Handle Optimistic Updates Correctly
// β
With rollback
const mutation = useMutation({
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previous = queryClient.getQueryData(['posts']);
queryClient.setQueryData(['posts'], newData);
return { previous }; // Context for rollback
},
onError: (err, newData, context) => {
queryClient.setQueryData(['posts'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
9. Use Query Cancellation
// β
Cancel on unmount or new fetch
const fetchPosts = async ({ signal }: { signal: AbortSignal }) => {
const response = await fetch('/posts', { signal });
return response.json();
};
export const postsQueryOptions = () =>
queryOptions({
queryKey: ['posts'],
queryFn: ({ signal }) => fetchPosts({ signal }),
});
10. Avoid Over-Fetching with select
// β
Only subscribe to what you need
function UserName({ userId }: { userId: number }) {
const { data: name } = useQuery({
...userQueryOptions(userId),
select: user => user.name,
});
return <span>{name}</span>;
}
π Migration from v4
Key Breaking Changes
- Query keys must be arrays
// v4
queryKey: 'users' // β String not allowed
// v5
queryKey: ['users'] // β
Always array
-
cacheTimeβgcTime
// v4
cacheTime: 1000 * 60 * 10
// v5
gcTime: 1000 * 60 * 10
-
isLoadingβisPending
// v4
if (isLoading) return <Spinner />;
// v5
if (isPending) return <Spinner />; // No cached data
// or
if (isLoading) return <Spinner />; // First fetch (isPending && isFetching)
-
keepPreviousDataβplaceholderData
// v4
keepPreviousData: true
// v5
import { keepPreviousData } from '@tanstack/react-query';
placeholderData: keepPreviousData
-
onSuccess/onErrorremoved from queries
// v4
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
onSuccess: (data) => toast.success('Loaded!'), // β Removed
});
// v5 - Use useEffect instead
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
useEffect(() => {
if (query.isSuccess) {
toast.success('Loaded!');
}
}, [query.isSuccess]);
- Suspense: Use dedicated hooks
// v4
useQuery({
suspense: true, // β Removed
});
// v5
useSuspenseQuery({ // β
Dedicated hook
queryKey: ['users'],
queryFn: fetchUsers,
});
Migration Codemod
npx jscodeshift ./src \
--extensions=ts,tsx \
--parser=tsx \
--transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.js
π Troubleshooting
Issue: Query Not Refetching
Problem:
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// Data never updates!
Solutions:
// 1. Check staleTime
staleTime: 0 // Make data immediately stale
// 2. Check refetch settings
refetchOnWindowFocus: true
refetchOnReconnect: true
// 3. Manual invalidation
queryClient.invalidateQueries({ queryKey: ['users'] });
Issue: Infinite Refetching Loop
Problem:
useQuery({
queryKey: ['users', { filter: someObject }],
// ^^^ New object every render!
});
Solution:
// Memoize objects in query keys
const filters = useMemo(() => ({ status: 'active' }), []);
useQuery({
queryKey: ['users', filters], // β
Stable reference
});
Issue: TypeScript Errors with queryOptions
Problem:
const data = queryClient.getQueryData(userQueryOptions(5).queryKey);
// Type error: queryKey doesn't exist
Solution:
// Add 'as const' to query keys
export const userQueryOptions = (id: number) =>
queryOptions({
queryKey: ['users', id] as const, // β
as const
queryFn: () => fetchUser(id),
});
Issue: Stale Data After Mutation
Problem:
// Created new post but list doesn't update
Solutions:
// Option 1: Invalidate queries
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
// Option 2: Update cache directly
onSuccess: (newPost) => {
queryClient.setQueryData(['posts'], (old = []) => [...old, newPost]);
}
// Option 3: Refetch
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['posts'] });
}
π Resources
Official Documentation
Learning Resources
- TkDodo's Blog - Best practices & patterns
- Simple - Comprehensive course
Tools
Community
π Key Takeaways
-
Use
queryOptionspattern for type-safe, reusable queries -
Set appropriate
staleTimebased on data volatility - Leverage caching - avoid unnecessary refetches
- Optimistic updates for better UX
- Use Suspense for cleaner loading states
- Structure query keys hierarchically for better cache management
- Test with proper utilities - always wrap in QueryClientProvider
- Monitor with DevTools - understand what's happening
- Handle errors gracefully - component-level and global
- Keep queries co-located - maintain in dedicated files
π¨βπ» Author
Rajat
- GitHub: @rajat
β Support
If this guide helped you:
- Share with fellow developers
- Follow for more content
Happy Querying! π
Made with β€οΈ by Rajat

Top comments (0)