From Beginner to Pro: Mastering State Management with TanStack Query v5
The complete guide to building production-ready React apps with TanStack Query v5, queryOptions, and best practices
📋 Table of Contents
🚀 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( '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()
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
? '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.children;
export function ErrorBoundary( children, fallback : Props) {
const reset = useQueryErrorResetBoundary();
return (
<ErrorBoundaryClass
fallback=(error, resetError) =>
return (
fallback?.(error, () =>
reset();
resetError();
)
>
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
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
⭐ Support
If this guide helped you:
- Share with fellow developers
- Follow for more content
Happy Querying! 🚀
Made with ❤️ by Rajat

