Hello Everyone! 👋
I'm Rajat, and six months ago, I was drowning in React state management.
My components looked like this:
// My "organized" React component 🤦
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users?page=${page}`)
.then(res => res.json())
.then(data => {
setUsers(prev => [...prev, ...data]);
setHasMore(data.length > 0);
})
.catch(err => setError(err))
.finally(() => setLoading(false));
}, [page]);
// Another component needs this data? Copy-paste everything! 😭
}
My problems:
- 50+ lines of state management for simple data fetching
- Loading states everywhere (still forgot them half the time)
- Stale data (refresh = start over)
- No caching (same API called 10 times)
- Copy-pasted this pattern 30+ times
Then I discovered TanStack Query v5:
// Same functionality in 5 lines!
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'], // Automatic caching by key
queryFn: () => fetch('/api/users').then(r => r.json()) // That's it!
});
// Automatic refetching, caching, background updates, the works! ✨
}
What changed: TanStack Query handles ALL the complexity I was manually coding. Today, I'm going to show you everything I wish I knew on day 1 - with all my mistakes so you don't make them!
🤔 Why TanStack Query Clicked For Me
The Lightbulb Moment
I was building a dashboard. Users table, posts table, comments - you know the drill. My code looked like:
// Component 1: Fetch users
const [users, setUsers] = useState([]);
useEffect(() => { /* fetch logic */ }, []);
// Component 2: Also needs users (5 minutes later)
const [users, setUsers] = useState([]); // Wait, I already fetched this!
useEffect(() => { /* same fetch logic copy-pasted */ }, []);
// Result: Called /api/users TWICE
// Wrote the SAME code TWICE
// Bugs in BOTH places when I "fixed" one
The realization: I was treating server data like client state. They're NOT the same!
Client state:
- You control it (counter, form inputs, modal open/closed)
- Lives in your app
- You decide when it changes
Server state:
- You DON'T control it (someone else's database)
- Lives on a server
- Can change without you knowing
- Needs fetching, caching, syncing
TanStack Query's magic: It treats server data as server data!
📚 What This Complete Guide Covers
Full honesty:
- I'm a student, NOT a React expert (yet! 🤞)
- This took me 6 months to truly understand
- I made every mistake possible (documented them all!)
- If these patterns help me, they'll help you!
What you'll learn (EVERYTHING in this one post):
- ✅ Complete setup from scratch
- ✅ Every TanStack Query concept explained
- ✅ The
queryOptionspattern (game-changer!) - ✅ Real authentication example
- ✅ Mutations & optimistic updates
- ✅ Infinite scrolling that actually works
- ✅ Error handling (global + local)
- ✅ Performance optimization
- ✅ Testing strategies
- ✅ Every line of code explained
- ✅ All my mistakes with fixes
- ✅ Production-ready patterns
Time investment: One focused weekend
Difficulty: Beginner-friendly (I explain EVERYTHING)
Prerequisites: Basic React (useState, useEffect)
Note: This is the COMPLETE guide - no "Part 2 next week." Everything is here!
⚡ Quick Start (See It Work First!)
I know you want to see it work FIRST, understand later. Let's go!
1. Create React Project
# Using Vite (recommended - SO much faster than CRA)
npm create vite@latest tanstack-query-app -- --template react-ts
# ↑
# TypeScript template (trust me on this!)
cd tanstack-query-app
Mistake I made: Used Create React App. Vite is 10x faster. Learn from my pain.
2. Install TanStack Query
# The library itself
npm install @tanstack/react-query
# DevTools (THIS WILL SAVE YOUR LIFE - seriously, install it!)
npm install @tanstack/react-query-devtools
# Axios for API calls (better than fetch)
npm install axios
3. Setup (The Magic Config)
Create src/lib/query-client.ts:
import { QueryClient } from '@tanstack/react-query';
// This is your "database" of server data in the browser
export const queryClient = new QueryClient({
defaultOptions: { // These are DEFAULTS (can override per query)
queries: { // Settings for ALL queries
staleTime: 1000 * 60 * 5, // 5 minutes - How long data stays "fresh"
// ↑ ↑ ↑
// ms sec min
// Translation: "Trust this data for 5 minutes before refetching"
gcTime: 1000 * 60 * 10, // 10 minutes - Garbage collection time
// When to delete unused data from memory
// OLD NAME in v4: cacheTime (they renamed it in v5)
retry: 3, // Retry failed requests 3 times before giving up
// Useful for flaky networks!
refetchOnWindowFocus: false, // Don't refetch when I tab back
// I turned this OFF (annoying during development)
// You might want it ON in production!
refetchOnReconnect: true, // DO refetch when internet comes back
// This one is useful!
},
mutations: { // Settings for ALL mutations (create/update/delete)
retry: 1, // Only retry once (mutations are more risky)
},
},
});
What I learned: These defaults are SMART. They prevent:
- Hammering your API (staleTime)
- Memory leaks (gcTime)
- Failed requests (retry)
- Stale data after network issues (refetchOnReconnect)
4. Wrap Your App
Edit src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query'; // The provider
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; // The magical panel
import { queryClient } from './lib/query-client'; // Our config
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* Wrap EVERYTHING in QueryClientProvider */}
<QueryClientProvider client={queryClient}>
{/* ↑ This makes TanStack Query available everywhere */}
<App /> {/* Your actual app */}
{/* DevTools - floating panel in corner */}
<ReactQueryDevtools initialIsOpen={false} />
{/* ↑ Opens with a button click. SO USEFUL for debugging! */}
{/* You can see all queries, their states, data, everything! */}
</QueryClientProvider>
</React.StrictMode>
);
Why wrap everything? React Context pattern - makes queryClient available to all components without prop drilling!
5. Your First Query (The Magic!)
Create src/components/UserList.tsx:
import { useQuery } from '@tanstack/react-query'; // The main hook!
// Define what a User looks like (TypeScript is your friend)
interface User {
id: number;
name: string;
email: string;
}
export function UserList() {
// THIS IS THE MAGIC LINE
const { data, isLoading, error } = useQuery({
// Query key - uniquely identifies this query
queryKey: ['users'], // MUST be an array (v5 requirement)
// ↑ This is like a cache key
// Same key = same data = automatic sharing between components!
// Query function - HOW to fetch the data
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) { // Always check for errors!
throw new Error('Failed to fetch users');
}
return response.json() as Promise<User[]>;
// ↑ TypeScript knows data is User[]
},
});
// Loading state - TanStack Query manages this FOR YOU
if (isLoading) {
return <div>Loading users...</div>;
// Shows during first fetch
}
// Error state - Also managed FOR YOU
if (error) {
return <div>Error: {error.message}</div>;
// Shows if fetch failed
}
// Success state - data is ALWAYS defined here
return (
<div>
<h2>Users</h2>
<ul>
{data?.map(user => ( // ?. because TypeScript (but data IS defined here)
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}
6. Use It in Your App
Edit src/App.tsx:
import { UserList } from './components/UserList';
function App() {
return (
<div className="App">
<h1>TanStack Query Demo</h1>
<UserList />
</div>
);
}
export default App;
7. Run It!
npm run dev
Visit http://localhost:5173 and:
- See users load ✅
- Open DevTools (floating button in corner) ✅
- See your query, its state, the data ✅
- Refresh page - data loads INSTANTLY (cached!) ✅
You just built a React app with automatic:
- Caching
- Loading states
- Error handling
- Background refetching
- State sharing
In 20 lines of code! 🎉
🧠 Core Concepts (The "Aha!" Moments)
Query Keys (The Most Important Concept!)
What confused me: "Why are query keys arrays? Why not just strings?"
The answer: Query keys are like file paths on your computer.
// Think of it like a file system
['users'] // /users (all users)
['users', 5] // /users/5 (user #5)
['users', 5, 'posts'] // /users/5/posts (user 5's posts)
['users', { status: 'active' }] // /users?status=active (filtered users)
Rules I learned the hard way:
// ✅ GOOD - Consistent keys
['users', userId] // userId is a number
// ❌ BAD - Inconsistent types
['users', userId] // Sometimes number, sometimes string!
// Result: TWO different cache entries!
// ['users', 5] ≠ ['users', '5'] // 😱
// ✅ GOOD - Objects are fine (deep equality)
['users', { status: 'active', role: 'admin' }]
// ✅ GOOD - Hierarchical
['projects', projectId, 'tasks', taskId]
// ❌ BAD - Order matters!
['users', 5] ≠ [5, 'users'] // Different keys!
My mistake: I changed key structures mid-project. Had TWO separate caches for the same data. Took me 3 days to debug! 😭
Query States (Understanding the Lifecycle)
v5 changed this! v4 had isLoading, v5 adds isPending. Here's why:
const {
isPending, // ← NEW in v5
isLoading, // ← Changed meaning in v5
isError,
isSuccess,
isFetching,
isRefetching,
isStale,
data,
error
} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
The states explained:
// FIRST LOAD (no cached data)
isPending: true // ← "I have NO data yet"
isLoading: true // ← "First fetch happening" (isPending + isFetching)
isFetching: true // ← "Actively fetching"
// AFTER FIRST LOAD (data in cache)
isPending: false // ← "I have data (even if stale)"
isLoading: false // ← "Not first load anymore"
isFetching: true // ← "Background refetch happening"
isRefetching: true // ← "Refetching existing data"
Why this matters:
// ❌ WRONG - Shows spinner on every refetch!
if (isLoading) return <Spinner />;
// ✅ RIGHT - Only shows spinner on FIRST load
if (isPending) return <Spinner />;
if (isError) return <Error error={error} />;
return (
<div>
{isFetching && <div className="refresh-indicator">Updating...</div>}
{/* ↑ Small indicator for background updates */}
<UserList users={data} />
{/* ↑ Still shows old data while fetching new */}
</div>
);
My mistake: Used isLoading everywhere. Every background refetch showed a spinner. Users thought the app was broken! 🤦
Stale Time vs GC Time (The Cache Lifecycle)
This confused me for WEEKS. Here's the mental model that finally clicked:
Fetch → [Fresh Period] → [Stale Period] → [Unused Period] → Deleted
└─ staleTime ─┘ └──── still in cache ────┘ └─ gcTime ─┘
Example timeline:
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
});
// t=0: Fetch happens
// Data is FRESH ✨
// Query won't refetch (data is trusted)
// t=5min: staleTime expires
// Data becomes STALE 🟡
// Still shows immediately (cached)
// BUT refetches in background on mount/focus
// t=15min: No component using this query
// gcTime expires
// Data DELETED from cache 🗑️
// Next fetch = brand new request
Real example:
// User profile (changes rarely)
staleTime: 1000 * 60 * 60, // 1 hour - trust data for long time
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache even longer
// Live stock prices (changes every second)
staleTime: 0, // Always stale, always refetch
gcTime: 1000 * 60, // 1 minute - don't keep long
// App config (never changes)
staleTime: Infinity, // Never stale!
gcTime: Infinity, // Never delete!
My mistake: Set staleTime: 0 everywhere. Hammered my API. Got rate-limited. Oops! 😅
🎯 The queryOptions Pattern (The Game-Changer!)
This is THE recommended pattern in v5. Once I learned this, everything clicked!
Why queryOptions? (My Journey)
Before queryOptions (v4 style):
// UserList.tsx
const { data } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
staleTime: 300000,
});
// UserDetail.tsx (different file)
const { data } = useQuery({
queryKey: ['users', userId], // Same key? Did I spell it right?
queryFn: () => fetchUser(userId), // Same function? Copy-pasted!
staleTime: 300000, // Same config? Probably!
});
// Prefetch somewhere
queryClient.prefetchQuery({
queryKey: ['user', userId], // OOPS! Typo! 'user' not 'users'
queryFn: () => fetchUser(userId),
});
// Result: Prefetched to DIFFERENT key. Doesn't help at all! 😭
Problems:
- Keys can drift (typos)
- Functions copy-pasted
- No type safety
- Can't reuse easily
After queryOptions (v5 style):
// queries/users.queries.ts (ONE place for EVERYTHING)
import { queryOptions } from '@tanstack/react-query';
export const userQueryOptions = (userId: number) =>
queryOptions({
queryKey: ['users', userId] as const, // Defined ONCE
queryFn: () => fetchUser(userId), // Defined ONCE
staleTime: 300000, // Defined ONCE
});
// UserList.tsx
const { data } = useQuery(userQueryOptions(5));
// ↑ Just call the function!
// UserDetail.tsx
const { data } = useQuery(userQueryOptions(5));
// ↑ Same function! Can't make mistakes!
// Prefetch
queryClient.prefetchQuery(userQueryOptions(5));
// ↑ Same function! Guaranteed correct!
// Type-safe cache access
const user = queryClient.getQueryData(userQueryOptions(5).queryKey);
// ↑ TypeScript KNOWS this is User | undefined!
Benefits:
- ✅ Keys defined once (no typos)
- ✅ Functions defined once (no drift)
- ✅ Config defined once (consistent)
- ✅ Type-safe everywhere
- ✅ Easy to refactor (change in one place)
Complete Real-World Example (Posts API)
Let me show you a REAL production-ready pattern:
Step 1: Setup Axios
Create src/api/axios-instance.ts:
import axios from 'axios';
// Create axios instance with base config
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com',
// ↑ Use environment variable if available, fallback to placeholder API
headers: {
'Content-Type': 'application/json', // Always send JSON
},
});
// Request interceptor - runs BEFORE every request
api.interceptors.request.use(
config => {
// Add auth token from localStorage
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
// ↑ Automatically add token to every request!
}
return config; // Must return config
},
error => {
// Handle request errors (rare, but possible)
return Promise.reject(error);
}
);
// Response interceptor - runs AFTER every response
api.interceptors.response.use(
response => response, // Success - just pass through
error => {
// Handle response errors (common!)
if (error.response?.status === 401) {
// Unauthorized - token expired or invalid
localStorage.removeItem('token'); // Clear invalid token
window.location.href = '/login'; // Redirect to login
// This runs on EVERY 401, automatically!
}
return Promise.reject(error); // Must reject for error handling
}
);
Why interceptors are amazing:
- Write auth logic ONCE
- Applies to ALL requests
- Handle errors globally
- No repetition!
Step 2: Define Types
Create src/types/post.types.ts:
// What we GET from API
export interface Post {
id: number;
userId: number;
title: string;
body: string;
}
// What we SEND to create
export interface CreatePostDto { // DTO = Data Transfer Object
userId: number;
title: string;
body: string;
// No id - server generates it
}
// What we SEND to update
export interface UpdatePostDto {
title?: string; // ? = optional
body?: string; // Maybe only updating title, not body
}
// Filters for queries
export interface PostFilters {
userId?: number; // Filter by user
search?: string; // Search in title/body
status?: 'published' | 'draft'; // Filter by status
}
Why separate types?
- API shape ≠ Form shape
- Create ≠ Update (different fields)
- Type safety for everything!
Step 3: Create Query Options (THE PATTERN)
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 (HOW to fetch)
// ============================================
const fetchPosts = async (filters?: PostFilters): Promise<Post[]> => {
const { data } = await api.get<Post[]>('/posts', {
params: filters // Axios converts to query string: /posts?userId=1&search=test
});
return data;
// TypeScript knows data is Post[]
};
const fetchPostById = async (id: number): Promise<Post> => {
const { data } = await api.get<Post>(`/posts/${id}`);
// ↑ Destructure data from response (Axios wraps it)
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
*
* Usage: useQuery(postsQueryOptions({ userId: 1 }))
*/
export const postsQueryOptions = (filters?: PostFilters) =>
queryOptions({
// Query key includes filters - different filters = different cache entry
queryKey: ['posts', filters] as const,
// ↑ ↑ Filters in key means automatic cache separation
// | ['posts', {userId:1}] ≠ ['posts', {userId:2}]
// | Base key for all posts
queryFn: () => fetchPosts(filters),
// ↑ Arrow function wraps our async function
staleTime: 1000 * 60 * 5, // 5 minutes
// Posts don't change super fast, 5 min is reasonable
});
/**
* Fetch a single post by ID
*
* Usage: useQuery(postQueryOptions(5))
*/
export const postQueryOptions = (id: number) =>
queryOptions({
queryKey: ['posts', id] as const,
// ↑ ↑ Hierarchical key
// | ['posts', 5] = "post #5 in the posts collection"
// | Parent key
queryFn: () => fetchPostById(id),
staleTime: 1000 * 60 * 5,
// Only fetch if id is valid (prevents errors on mount)
enabled: id > 0,
// If id is 0 or negative, query won't run
// Useful for dependent queries!
});
/**
* Fetch posts by user ID
*
* Usage: useQuery(userPostsQueryOptions(userId))
*/
export const userPostsQueryOptions = (userId: number) =>
queryOptions({
// Different structure = different cache entry from postsQueryOptions
queryKey: ['posts', 'user', userId] as const,
// ↑ ↑ ↑
// | | | User-specific posts
// | | Namespace for user-filtered posts
// | Base collection
queryFn: () => fetchPostsByUser(userId),
staleTime: 1000 * 60 * 3, // 3 minutes (faster changing)
// User's posts might be created/edited more frequently
});
/**
* Infinite query for paginated posts
*
* Usage: useInfiniteQuery(postsInfiniteQueryOptions())
*
* NEW in v5: infiniteQueryOptions helper!
*/
export const postsInfiniteQueryOptions = () =>
infiniteQueryOptions({
queryKey: ['posts', 'infinite'] as const,
// ↑ ↑ Different key = separate cache
// | | Infinite scroll has its own cache
// | Same base collection
queryFn: async ({ pageParam }) => {
// ↑ TanStack Query provides this
// First page: pageParam = initialPageParam (1)
// Next pages: pageParam = whatever getNextPageParam returns
const { data } = await api.get<Post[]>('/posts', {
params: {
_page: pageParam, // Current page number
_limit: 10 // Posts per page
},
});
return data;
// Returns ONE page of data
},
initialPageParam: 1, // Start at page 1
// REQUIRED in v5! (was optional in v4)
getNextPageParam: (lastPage, allPages) => {
// ↑ ↑
// | | Array of all pages fetched so far
// | The page we just fetched
// Logic: If last page had 10 items, there might be more
return lastPage.length === 10
? allPages.length + 1 // Next page number
: undefined; // No more pages (stop fetching)
},
staleTime: 1000 * 60 * 5,
// NEW in v5: Limit pages in cache!
maxPages: 3,
// Only keep last 3 pages in cache
// Prevents memory bloat on infinite scroll
// Older pages get garbage collected
});
Key learnings:
- Query keys are hierarchical - organize like a file system
- Filters in keys - automatic cache separation
-
as const- makes TypeScript happy (literal types) - Separate files - queries in one place, components in another
- JSDoc comments - helps your future self!
Using Query Options in Components
Now the easy part - using them!
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { postsQueryOptions, postQueryOptions } from '../queries/posts.queries';
// ============================================
// Regular Query (handles all states)
// ============================================
function PostList() {
const { data: posts, isPending, isError, error } = useQuery(
postsQueryOptions()
// ↑ Just call the function! No config needed!
);
if (isPending) return <div>Loading posts...</div>;
// ↑ Shows on first load only
if (isError) return <div>Error: {error.message}</div>;
// ↑ Shows if fetch failed
return (
<div>
{posts?.map(post => ( // posts is Post[] | undefined
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// ============================================
// With Filters (automatic cache separation)
// ============================================
function FilteredPostList({ userId }: { userId: number }) {
const { data: posts } = useQuery(
postsQueryOptions({ userId })
// ↑ Different filters = different cache!
// postsQueryOptions({ userId: 1 }) ≠ postsQueryOptions({ userId: 2 })
);
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));
// ↑ useSUSPENSEQuery, not useQuery
// ↑ data is NEVER undefined with Suspense!
// No isPending check needed - Suspense handles it!
return (
<article>
<h1>{post.title}</h1>
{/* ↑ TypeScript knows post is Post (not Post | undefined) */}
<p>{post.body}</p>
</article>
);
}
// Must wrap in Suspense boundary
function PostDetailPage({ id }: { id: number }) {
return (
<Suspense fallback={<PostSkeleton />}>
{/* ↑ Shows while PostDetail is loading */}
<PostDetail id={id} />
{/* ↑ Only renders when data is ready */}
</Suspense>
);
}
Suspense benefits:
- No
isPendingchecks - Cleaner code
- Loading handled at boundary level
- Data is NEVER undefined
My mistake: Tried Suspense in v4 (experimental). Had weird bugs. v5 is STABLE - use it! ✨
🔍 Advanced Query Patterns
Dependent Queries (Query B needs Query A's data)
Problem: Need to fetch user, THEN fetch their posts.
function UserPosts({ email }: { email: string }) {
// STEP 1: Get user by email
const { data: user } = useQuery({
queryKey: ['users', 'by-email', email],
// ↑ ↑ Namespace for email-based queries
queryFn: () => fetchUserByEmail(email),
});
// STEP 2: Get posts (only runs when user exists!)
const { data: posts } = useQuery({
queryKey: ['posts', 'user', user?.id],
// ↑ ↑ user.id in key
queryFn: () => fetchUserPosts(user!.id),
// ↑ Non-null assertion (we know it's defined)
enabled: !!user?.id, // ← THE MAGIC LINE
// Only fetch posts if user.id exists
// Before user loads, this query is PAUSED
});
// Handle states
if (!user) return <div>Loading user...</div>;
if (!posts) return <div>Loading posts...</div>;
return <PostList posts={posts} />;
}
How it works:
- Mount component → first query runs
- First query finishes → user has data
-
enabled: !!user?.idbecomes true - Second query starts
- Both queries complete → render
My mistake: Forgot enabled. Second query ran immediately with undefined id. Got 404 errors! 🤦
Parallel Queries (Fetch multiple things at once)
Problem: Dashboard needs users, posts, and comments.
function Dashboard() {
// All three queries run IN PARALLEL (not sequential)
const users = useQuery(usersQueryOptions());
const posts = useQuery(postsQueryOptions());
const comments = useQuery(commentsQueryOptions());
// Check if ANY query is loading
const isLoading = users.isPending || posts.isPending || comments.isPending;
if (isLoading) {
return <Spinner />;
}
// All queries succeeded
return (
<div>
<Stats
users={users.data}
posts={posts.data}
comments={comments.data}
/>
</div>
);
}
Performance:
- Parallel: ~300ms (all fetch together)
- Sequential: ~900ms (one after another)
- 3x faster! ⚡
useQueries (Dynamic list of queries)
Problem: Fetch data for multiple users (count unknown at compile time).
import { useQueries } from '@tanstack/react-query';
function MultipleUsers({ userIds }: { userIds: number[] }) {
// userIds might be [1, 5, 10] or [2, 3, 4, 8, 12] - we don't know!
const userQueries = useQueries({
queries: userIds.map(id => userQueryOptions(id)),
// ↑ Creates array of query configs
// If userIds = [1, 5, 10], creates:
// [
// userQueryOptions(1),
// userQueryOptions(5),
// userQueryOptions(10)
// ]
// NEW in v5: Combine results!
combine: results => ({
// results is array of query results
// results[0] = user 1's query
// results[1] = user 5's query, etc.
data: results.map(r => r.data),
// Extract all data: [user1, user5, user10]
isPending: results.some(r => r.isPending),
// isPending = true if ANY query is pending
}),
});
if (userQueries.isPending) {
return <div>Loading users...</div>;
}
return (
<div>
{userQueries.data.map((user, idx) => (
// user might be undefined if query failed
user && <UserCard key={userIds[idx]} user={user} />
))}
</div>
);
}
Use cases:
- Dynamic lists
- Selected items
- Batch operations
Prefetching (Load data before it's needed)
Problem: User hovers over link - make next page instant!
import { useQueryClient } from '@tanstack/react-query';
function PostPreview({ postId }: { postId: number }) {
const queryClient = useQueryClient();
// Prefetch on hover
const handleMouseEnter = () => {
queryClient.prefetchQuery(postQueryOptions(postId));
// ↑ Fetches and caches data
// When user clicks, data is already there!
};
return (
<Link
to={`/posts/${postId}`}
onMouseEnter={handleMouseEnter}
// ↑ Triggers on hover (before click!)
>
View Post
</Link>
);
}
// With React Router loader
export const postDetailLoader = async ({ params }: LoaderFunctionArgs) => {
const postId = Number(params.id);
// Prefetch before component renders
await queryClient.prefetchQuery(postQueryOptions(postId));
return null; // Loader doesn't return data (TanStack Query handles it)
};
Result: Instant page transitions! Users think your app is magic. ✨
Initial Data (Avoid loading states)
Problem: Already have data from list, don't fetch again for detail.
function PostDetail({ postId }: { postId: number }) {
const queryClient = useQueryClient();
const { data: post } = useQuery({
...postQueryOptions(postId),
// Try to get data from posts list cache
initialData: () => {
const posts = queryClient.getQueryData(postsQueryOptions().queryKey);
// ↑ Get posts list from cache
return posts?.find(p => p.id === postId);
// If post is in list, use it as initial data
// If not found, returns undefined (fetch normally)
},
// Only use initial data if it's fresh
initialDataUpdatedAt: () => {
const state = queryClient.getQueryState(postsQueryOptions().queryKey);
return state?.dataUpdatedAt;
// If posts list is stale, don't use initial data
},
});
return <PostCard post={post} />;
}
Result: Detail page shows instantly with list data, then updates in background if stale!
Placeholder Data (Show old data while fetching new)
NEW in v5! Renamed from keepPreviousData.
function PostList({ userId }: { userId: number }) {
const { data: posts, isPlaceholderData } = useQuery({
...postsQueryOptions({ userId }),
// Keep previous data while fetching new
placeholderData: previousData => previousData,
// ↑ Function that receives previous data
// ↑ Return it to keep showing it
});
return (
<div>
{/* Visual indicator during background fetch */}
{isPlaceholderData && (
<div className="opacity-50">Updating...</div>
// ↑ Dims the data to show it's updating
)}
<PostList posts={posts} />
{/* ↑ Shows old data until new data arrives */}
</div>
);
}
// Built-in helper (v5)
import { keepPreviousData } from '@tanstack/react-query';
const { data } = useQuery({
queryKey: ['posts', userId],
queryFn: fetchPosts,
placeholderData: keepPreviousData, // Same as above function
// ↑ Convenience helper
});
Use cases:
- Paginated lists (don't flash empty on page change)
- Filtered lists (don't flash while applying filter)
- Search results (show old while searching new)
My mistake: In v4, I used keepPreviousData: true. In v5, it's placeholderData. Migration broke my code! 😅
✏️ Mutations (Create, Update, Delete)
Queries = READ (GET requests)
Mutations = WRITE (POST/PUT/DELETE requests)
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, postQueryOptions } from './posts.queries';
// ============================================
// CREATE Post
// ============================================
export function useCreatePost() {
const queryClient = useQueryClient();
// ↑ Need this to invalidate queries after mutation
return useMutation({
// The actual API call
mutationFn: async (newPost: CreatePostDto): Promise<Post> => {
const { data } = await api.post<Post>('/posts', newPost);
// ↑ POST request
return data; // Returns created post (with id from server)
},
// After successful mutation
onSuccess: () => {
// Invalidate posts list - forces refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
// ↑ Marks all queries with key ['posts'] as stale
// ↑ Next time component renders, they refetch
// Result: List updates with new post!
},
});
}
// ============================================
// UPDATE Post
// ============================================
export function useUpdatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: { id: number } & Partial<Post>) => {
// ↑ Destructure id from object, rest in updates
const { data } = await api.patch<Post>(`/posts/${id}`, updates);
// ↑ PATCH = partial update
return data;
},
onSuccess: (updatedPost) => {
// ↑ The returned data from mutationFn
// Update single item in cache immediately
queryClient.setQueryData(
postQueryOptions(updatedPost.id).queryKey,
updatedPost
// ↑ Directly update cache without refetch
);
// Invalidate lists (might be in filtered lists too)
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false // Match ['posts'] and ['posts', ...anything]
});
},
});
}
// ============================================
// DELETE Post
// ============================================
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await api.delete(`/posts/${id}`);
// ↑ DELETE request
return id; // Return id for use in callbacks
},
onSuccess: (deletedId) => {
// Remove from cache
queryClient.removeQueries({
queryKey: ['posts', deletedId]
});
// ↑ Completely remove from cache (not just invalidate)
// Invalidate lists
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false
});
},
});
}
Using Mutations in Components
import { useCreatePost, useUpdatePost } from '../queries/posts.mutations';
import { FormEvent } from 'react';
function CreatePostForm() {
const createPost = useCreatePost();
// ↑ Returns mutation object
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Trigger mutation
createPost.mutate(
// Data to send
{
userId: 1,
title: formData.get('title') as string,
body: formData.get('body') as string,
},
// Callbacks (optional)
{
onSuccess: () => {
alert('Post created! ✅');
e.currentTarget.reset(); // Clear form
},
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}
// ↑ Disable during mutation
>
{createPost.isPending ? 'Creating...' : 'Create Post'}
{/* ↑ Show loading text */}
</button>
{createPost.isError && (
<p className="error">Error: {createPost.error.message}</p>
)}
</form>
);
}
Mutation states:
createPost.isPending // Currently running
createPost.isSuccess // Completed successfully
createPost.isError // Failed
createPost.data // Returned data
createPost.error // Error object
createPost.variables // What you passed to mutate()
Optimistic Updates (Make UI instant!)
Problem: User clicks "Like". Button updates only after server responds (slow!).
Solution: Update UI immediately, rollback if server fails.
export function useTogglePostLike() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (postId: number) => {
// Actually call API
const { data } = await api.post(`/posts/${postId}/like`);
return data;
},
// BEFORE mutation runs (optimistic!)
onMutate: async (postId) => {
// ↑ The variable passed to mutate()
// 1. Cancel outgoing refetches (don't overwrite our optimistic update)
await queryClient.cancelQueries({
queryKey: ['posts', postId]
});
// 2. Snapshot previous value (for rollback)
const previousPost = queryClient.getQueryData(
postQueryOptions(postId).queryKey
);
// 3. Optimistically update UI
queryClient.setQueryData(
postQueryOptions(postId).queryKey,
(old: Post) => ({
...old,
likes: old.likes + 1, // Increment likes immediately
})
);
// 4. Return context (passed to onError for rollback)
return { previousPost };
},
// If mutation FAILS
onError: (err, postId, context) => {
// ↑ ↑ ↑
// | | | Context from onMutate
// | | Variables passed to mutate
// | Error object
// Rollback to previous value
queryClient.setQueryData(
postQueryOptions(postId).queryKey,
context?.previousPost // Restore snapshot
);
alert('Failed to like post'); // Show error
},
// After success OR error
onSettled: (data, error, postId) => {
// Always refetch to get server truth
queryClient.invalidateQueries({
queryKey: ['posts', postId]
});
},
});
}
// Usage
function LikeButton({ postId }: { postId: number }) {
const toggleLike = useTogglePostLike();
return (
<button
onClick={() => toggleLike.mutate(postId)}
disabled={toggleLike.isPending}
>
👍 Like
{/* Show indicator while saving */}
{toggleLike.variables === postId && ' (saving...)'}
{/* ↑ variables = the postId we're currently mutating */}
</button>
);
}
Flow:
- User clicks →
onMutate→ UI updates instantly ⚡ - API call starts in background
- If success →
onSettled→ refetch (confirm UI is correct) - If error →
onError→ rollback → show error
Result: Feels instant even on slow networks!
My mistake: Forgot onError rollback. When API failed, UI showed wrong state! 😭
Optimistic Updates with Lists
More complex - updating an item in a list:
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) => {
// Cancel refetches
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot
const previousPosts = queryClient.getQueryData(
postsQueryOptions().queryKey
);
// Create optimistic post with temporary ID
const optimisticPost: Post = {
id: Date.now(), // Temporary ID (will be replaced by server)
...newPost,
};
// Add to list immediately
queryClient.setQueryData(
postsQueryOptions().queryKey,
(old: Post[] = []) => [optimisticPost, ...old]
// ↑ Add to beginning
);
return { previousPosts };
},
onError: (err, newPost, context) => {
// Rollback entire list
queryClient.setQueryData(
postsQueryOptions().queryKey,
context?.previousPosts
);
},
onSuccess: (createdPost) => {
// Replace optimistic post with real one (has real ID)
queryClient.setQueryData(
postsQueryOptions().queryKey,
(old: Post[] = []) =>
old.map(post =>
post.id === createdPost.id ? createdPost : post
// ↑ Find optimistic post by matching ID
)
);
},
});
}
Tricky parts:
- Temporary ID (must be unique)
- Replacing optimistic with real
- Handling rollback of entire list
useMutationState (NEW in v5!)
Problem: Want to show global loading indicator for ALL mutations.
import { useMutationState } from '@tanstack/react-query';
function GlobalLoadingIndicator() {
// Get ALL pending mutations across entire app
const pendingMutations = useMutationState({
filters: { status: 'pending' },
// ↑ Only pending ones
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 bg-blue-500 text-white">
{pendingMutations.map((m, i) => (
<div key={i}>
Saving {JSON.stringify(m.mutationKey)}...
</div>
))}
</div>
);
}
// Check if specific mutation type is running
function useIsCreatingPost() {
const mutations = useMutationState({
filters: {
mutationKey: ['posts', 'create'], // Only create post mutations
status: 'pending'
},
});
return mutations.length > 0;
// Returns true if ANY create post mutation is running
}
Use cases:
- Global loading indicators
- Prevent navigation during saves
- Show "unsaved changes" warning
- Track mutation history
🚀 Infinite Queries (Pagination Made Easy!)
Problem: Load more posts as user scrolls.
Basic Infinite Query
import { useInfiniteQuery } from '@tanstack/react-query';
import { postsInfiniteQueryOptions } from '../queries/posts.queries';
function InfinitePostList() {
const {
data, // All pages fetched so far
fetchNextPage, // Function to load more
hasNextPage, // Are there more pages?
isFetchingNextPage, // Loading next page?
isLoading, // First load?
} = useInfiniteQuery(postsInfiniteQueryOptions());
if (isLoading) return <Spinner />;
return (
<div>
{/* data.pages is array of pages */}
{data?.pages.map((page, pageIndex) => (
<div key={pageIndex}>
{/* page is array of posts */}
{page.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
{/* Load more button */}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}
Data structure:
data = {
pages: [
[post1, post2, post3], // Page 1
[post4, post5, post6], // Page 2
[post7, post8, post9], // Page 3
],
pageParams: [1, 2, 3], // Page numbers
}
Auto-load with Intersection Observer
Better UX: Load more automatically when user scrolls near bottom.
import { useInView } from 'react-intersection-observer';
// ↑ npm install react-intersection-observer
import { useEffect } from 'react';
function AutoLoadInfiniteList() {
// Intersection observer hook
const { ref, inView } = useInView({
threshold: 0, // Trigger when ANY part visible
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery(postsInfiniteQueryOptions());
// Auto-fetch when sentinel is visible
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
// Loads next page automatically!
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map(page =>
page.map(post => (
<PostCard key={post.id} post={post} />
))
)}
{/* Sentinel element - invisible div at bottom */}
<div ref={ref} style={{ height: 20 }}>
{isFetchingNextPage && <Spinner />}
</div>
{/* ↑ When this scrolls into view, load more! */}
</div>
);
}
How it works:
- User scrolls down
- Sentinel div enters viewport
-
inViewbecomes true -
useEffecttriggers -
fetchNextPage()called - Next page loads
- Repeat!
Result: Instagram-style infinite scroll! ✨
⚡ Performance Optimization
1. Selective Re-renders with select
Problem: Component re-renders even when unrelated data changes.
// ❌ BAD - Re-renders on ANY user change
function UserName({ userId }: { userId: number }) {
const { data: user } = useQuery(userQueryOptions(userId));
// Re-renders even if only user.email changed!
return <div>{user?.name}</div>;
}
// ✅ GOOD - Only re-renders when name changes
function UserName({ userId }: { userId: number }) {
const { data: name } = useQuery({
...userQueryOptions(userId),
select: user => user.name,
// ↑ Transform data before returning
// Component only "sees" the name
// If name doesn't change, no re-render!
});
return <div>{name}</div>;
}
More examples:
// Extract specific fields
function PostTitles() {
const { data: titles } = useQuery({
...postsQueryOptions(),
select: posts => posts.map(p => p.title),
// Only re-renders if titles array changes
// Not if post bodies, dates, etc. change
});
return (
<ul>
{titles?.map(title => <li key={title}>{title}</li>)}
</ul>
);
}
// Compute derived state
function PostStats() {
const { data: stats } = useQuery({
...postsQueryOptions(),
select: posts => ({
total: posts.length,
published: posts.filter(p => p.published).length,
drafts: posts.filter(p => !p.published).length,
}),
// Only re-renders if these numbers change
});
return <div>Total: {stats?.total}</div>;
}
2. Structural Sharing (Automatic!)
TanStack Query is smart: It prevents re-renders if data shape doesn't change.
// Refetch every second
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
refetchInterval: 1000, // Refetch every 1 second
});
// But component only re-renders if todos actually changed!
// If API returns same todos, NO re-render
// TanStack Query does deep equality check automatically
How it works:
// First fetch
oldData = [{ id: 1, text: 'Buy milk' }]
// Second fetch (same data)
newData = [{ id: 1, text: 'Buy milk' }]
// TanStack Query: "These are equal!" → No re-render ✨
// Third fetch (different data)
newerData = [{ id: 1, text: 'Buy bread' }]
// TanStack Query: "These are different!" → Re-render
You get this for FREE! No extra code needed.
3. Query Splitting
Problem: Fetching too much data at once.
// ❌ BAD - One giant query
const { data: user } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUserWithEverything(id),
// Fetches user + posts + comments + profile + settings + friends + ...
// 500KB response! 😱
// Takes 3 seconds to load
// Can't cache parts separately
});
// ✅ GOOD - Split into focused queries
const { data: user } = useQuery(userQueryOptions(id));
// 5KB, loads instantly
const { data: posts } = useQuery(userPostsQueryOptions(id));
// 20KB, loads next
const { data: settings } = useQuery(userSettingsQueryOptions(id));
// 2KB, loads last
// Benefits:
// - Each part cacheable separately
// - Show user immediately while posts load
// - If only settings change, only refetch settings
4. Prefetching Strategies
Problem: Page transitions feel slow.
// Strategy 1: Prefetch on hover
function PostLink({ postId }: { postId: number }) {
const queryClient = useQueryClient();
return (
<Link
to={`/posts/${postId}`}
onMouseEnter={() => {
// Start fetching on hover (before click!)
queryClient.prefetchQuery(postQueryOptions(postId));
}}
>
View Post
</Link>
);
}
// Result: Page loads instantly on click!
// Strategy 2: Prefetch next page
function InfiniteList() {
const { data, hasNextPage } = useInfiniteQuery(
postsInfiniteQueryOptions()
);
useEffect(() => {
// Prefetch next page when user scrolls to 80%
const handleScroll = () => {
const scrolled = window.scrollY;
const total = document.body.scrollHeight - window.innerHeight;
const percentage = (scrolled / total) * 100;
if (percentage > 80 && hasNextPage) {
fetchNextPage(); // Prefetch before they hit bottom
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [hasNextPage]);
return <div>{/* ... */}</div>;
}
5. Disable Queries When Not Needed
function UserProfile({ userId }: { userId?: number }) {
const { data: user } = useQuery({
...userQueryOptions(userId!),
enabled: !!userId,
// ↑ Only fetch if userId is defined
// Prevents errors from fetching undefined
});
if (!userId) return <div>No user selected</div>;
if (!user) return <div>Loading...</div>;
return <UserCard user={user} />;
}
// More examples
const { data } = useQuery({
...dataQueryOptions(),
enabled: isLoggedIn, // Only fetch if logged in
});
const { data } = useQuery({
...dataQueryOptions(),
enabled: tabIsActive, // Only fetch if tab is visible
});
🛡️ Error Handling (The Right Way)
Component-Level Errors
function PostList() {
const { data, error, isError, refetch } = useQuery(postsQueryOptions());
if (isError) {
return (
<div className="error-container">
<h3>😢 Failed to load posts</h3>
<p>{error.message}</p>
<button onClick={() => refetch()}>Try Again</button>
{/* ↑ refetch = try the query again */}
</div>
);
}
return <div>{/* Normal render */}</div>;
}
Global Error Handling
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast'; // Or your toast library
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
// Show toast for ALL query errors
toast.error(`Query error: ${error.message}`);
},
},
mutations: {
onError: (error) => {
// Show toast for ALL mutation errors
toast.error(`Mutation error: ${error.message}`);
},
},
},
});
Error Boundaries (Recommended!)
// 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();
// ↑ Resets ALL queries when error boundary resets
return (
<ErrorBoundaryClass
fallback={(error, resetError) => {
return (
fallback?.(error, () => {
reset(); // Reset queries
resetError(); // Reset boundary
}) || (
<div>
<h3>Error: {error.message}</h3>
<button
onClick={() => {
reset();
resetError();
}}
>
Try again
</button>
</div>
)
);
}}
>
{children}
</ErrorBoundaryClass>
);
}
Usage:
function App() {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
{/* If ANY query throws, boundary catches it */}
<Suspense fallback={<Loading />}>
<Posts />
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
}
Typed Errors (TypeScript)
import axios, { AxiosError } from 'axios';
// Define error shape
interface ApiError {
message: string;
code: string;
statusCode: number;
}
// Type the query
export const postsQueryOptions = () =>
queryOptions<Post[], AxiosError<ApiError>>({
// ↑ ↑ Error type
// | Data type
queryKey: ['posts'],
queryFn: async () => {
const { data } = await api.get<Post[]>('/posts');
return data;
},
});
// Use 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}
{/* ↑ Full autocomplete! */}
</div>
);
}
return <div>{/* ... */}</div>;
}
🧪 Testing (Production-Ready)
Setup Testing Utils
// test/utils.tsx
import { ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Create query client for tests
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: Infinity, // Keep cache forever in tests
},
mutations: {
retry: false,
},
},
});
}
// Create wrapper component
interface WrapperProps {
children: ReactNode;
}
export function createWrapper() {
const testQueryClient = createTestQueryClient();
return ({ children }: WrapperProps) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
}
// Render helper
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
// queries/posts.queries.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { createWrapper } from '../test/utils';
import { useQuery } from '@tanstack/react-query';
import { postsQueryOptions } from './posts.queries';
import { vi } from 'vitest';
describe('Posts Query', () => {
it('should fetch posts successfully', async () => {
// Render hook with wrapper
const { result } = renderHook(
() => useQuery(postsQueryOptions()),
{ wrapper: createWrapper() }
);
// Initially pending
expect(result.current.isPending).toBe(true);
// Wait for success
await waitFor(() =>
expect(result.current.isSuccess).toBe(true)
);
// Check data
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() }
);
// Wait for error
await waitFor(() =>
expect(result.current.isError).toBe(true)
);
expect(result.current.error?.message).toBe('Network error');
});
});
Testing Mutations
// queries/posts.mutations.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useCreatePost } from './posts.mutations';
import { createWrapper } from '../test/utils';
describe('Create Post Mutation', () => {
it('should create a post', async () => {
const { result } = renderHook(
() => useCreatePost(),
{ wrapper: createWrapper() }
);
// Trigger mutation
result.current.mutate({
userId: 1,
title: 'Test Post',
body: 'Test Body',
});
// Wait for success
await waitFor(() =>
expect(result.current.isSuccess).toBe(true)
);
// Check returned data
expect(result.current.data).toMatchObject({
userId: 1,
title: 'Test Post',
body: 'Test Body',
});
});
});
Testing Components
// components/PostList.test.tsx
import { screen, waitFor } from '@testing-library/react';
import { renderWithClient } from '../test/utils';
import { PostList } from './PostList';
import { server } from '../mocks/server';
import { rest } from 'msw';
describe('PostList Component', () => {
it('should display posts', async () => {
renderWithClient(<PostList />);
// Check loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for posts to load
await waitFor(() => {
expect(screen.getByText('Post Title 1')).toBeInTheDocument();
});
});
it('should handle errors', async () => {
// Mock error response
server.use(
rest.get('/posts', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Server Error' })
);
})
);
renderWithClient(<PostList />);
// Wait for error message
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
💎 Best Practices (Lessons Learned)
1. Always Use queryOptions
// ✅ 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));
// Cache access
const user = queryClient.getQueryData(
userQueryOptions(5).queryKey
);
// ❌ NOT THIS
// Component
const { data } = useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
});
// Different file - keys might drift!
queryClient.prefetchQuery({
queryKey: ['user', id], // Typo! Different key!
queryFn: () => fetchUser(id),
});
2. Structure Keys Hierarchically
// ✅ GOOD - Predictable hierarchy
['posts'] // All posts
['posts', { status: 'published' }] // Filtered
['posts', postId] // Single post
['posts', postId, 'comments'] // Post's comments
['posts', postId, 'comments', commentId] // Single comment
// ❌ BAD - Flat, unpredictable
['allPosts']
['publishedPosts']
['post-123']
['commentsForPost-123']
['comment-456-for-post-123']
Why hierarchy matters:
// Invalidate all posts queries (including nested)
queryClient.invalidateQueries({
queryKey: ['posts']
});
// Invalidates:
// - ['posts']
// - ['posts', 5]
// - ['posts', 5, 'comments']
// etc.
3. Set Appropriate Stale Times
// Fast-changing (stock prices, live scores)
staleTime: 0 // 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
// Never changes (static content)
staleTime: Infinity
4. Handle Loading States Properly
// ✅ GOOD - Different UI for different states
function PostList() {
const { data, isPending, isError, isFetching } = useQuery(
postsQueryOptions()
);
if (isPending) return <Skeleton />; // First load
if (isError) return <Error />; // Failed
return (
<div>
{/* Small indicator for background refetch */}
{isFetching && <RefreshIndicator />}
{/* Data visible during refetch */}
<Posts posts={data} />
</div>
);
}
// ❌ BAD - Spinner on every refetch
if (isLoading) return <Spinner />;
5. Use Suspense for Cleaner Code
// ✅ With Suspense - cleaner!
function PostDetail({ id }: { id: number }) {
const { data: post } = useSuspenseQuery(postQueryOptions(id));
// ↑ useSUSPENSEQuery
// data is NEVER undefined!
return <PostCard post={post} />;
// No loading checks needed!
}
function Page() {
return (
<Suspense fallback={<Skeleton />}>
<PostDetail id={5} />
</Suspense>
);
}
// ❌ Without Suspense - more code
function PostDetail({ id }: { id: number }) {
const { data: post, isPending } = useQuery(postQueryOptions(id));
if (isPending) return <Skeleton />;
if (!post) return null;
return <PostCard post={post} />;
}
6. Invalidate Strategically
// ✅ Invalidate related queries
mutation.onSuccess = () => {
// Exact match - only ['posts']
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: true
});
// Prefix match - ['posts', ...anything]
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false // or omit (default)
});
// With predicate - custom logic
queryClient.invalidateQueries({
queryKey: ['posts'],
predicate: query =>
query.state.data?.userId === userId,
});
};
7. Use Query Cancellation
// ✅ Support cancellation
const fetchPosts = async ({ signal }: { signal: AbortSignal }) => {
const response = await fetch('/posts', { signal });
// ↑ Pass signal to fetch
return response.json();
};
export const postsQueryOptions = () =>
queryOptions({
queryKey: ['posts'],
queryFn: ({ signal }) => fetchPosts({ signal }),
// ↑ TanStack Query provides signal
});
// Benefit: Cancelled queries don't update state
// Prevents "Can't perform state update on unmounted component" warnings
8. 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,
// Only re-renders when name changes
});
return <span>{name}</span>;
}
// ❌ BAD - Re-renders on any user change
function UserName({ userId }: { userId: number }) {
const { data: user } = useQuery(userQueryOptions(userId));
return <span>{user?.name}</span>;
}
🔄 Migrating from v4 to v5
Key Breaking Changes
1. Query keys must be arrays
// v4 ❌
queryKey: 'users'
// v5 ✅
queryKey: ['users']
2. cacheTime → gcTime
// v4 ❌
cacheTime: 600000
// v5 ✅
gcTime: 600000
3. isLoading → isPending
// v4 (one state for everything)
if (isLoading) return <Spinner />;
// v5 (two states for clarity)
if (isPending) return <Spinner />; // No cached data
// or
if (isLoading) return <Spinner />; // First fetch only
4. keepPreviousData → placeholderData
// v4 ❌
keepPreviousData: true
// v5 ✅
import { keepPreviousData } from '@tanstack/react-query';
placeholderData: keepPreviousData
5. No onSuccess/onError in queries
// v4 ❌
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
onSuccess: (data) => {
toast.success('Loaded!');
},
});
// v5 ✅ - Use useEffect
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
useEffect(() => {
if (query.isSuccess) {
toast.success('Loaded!');
}
}, [query.isSuccess]);
6. Suspense uses dedicated hook
// v4 ❌
useQuery({
suspense: true
});
// v5 ✅
useSuspenseQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
🐛 Troubleshooting (I've Seen It All)
Issue: Query Not Refetching
// Problem
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// Data never updates!
// Solution 1: Check staleTime
staleTime: 0 // Make immediately stale
// Solution 2: Manual invalidation
queryClient.invalidateQueries({ queryKey: ['users'] });
// Solution 3: Enable refetch settings
refetchOnWindowFocus: true,
refetchOnReconnect: true,
Issue: Infinite Refetch Loop
// Problem
useQuery({
queryKey: ['users', { filter: someObject }],
// ↑ New object every render!
});
// Solution: Memoize objects
const filters = useMemo(() => ({ status: 'active' }), []);
useQuery({
queryKey: ['users', filters], // ✅ Stable reference
});
Issue: TypeScript Errors
// Problem
const data = queryClient.getQueryData(
userQueryOptions(5).queryKey
);
// Type error: Property 'queryKey' doesn't exist
// Solution: Add 'as const'
export const userQueryOptions = (id: number) =>
queryOptions({
queryKey: ['users', id] as const, // ✅
queryFn: () => fetchUser(id),
});
Issue: Stale Data After Mutation
// Problem: Created post but list doesn't update
// Solution 1: Invalidate
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
// Solution 2: Update cache
onSuccess: (newPost) => {
queryClient.setQueryData(
['posts'],
(old = []) => [...old, newPost]
);
}
// Solution 3: Refetch
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['posts'] });
}
🎓 Key Takeaways
What I learned in 6 months:
- queryOptions is essential - use it everywhere
- staleTime ≠ gcTime - understand the difference
- Query keys = cache keys - structure them hierarchically
- isPending vs isLoading - v5 made this clearer
- Suspense is stable - use it for cleaner code
- Optimistic updates - make UI feel instant
- select transforms - prevent unnecessary re-renders
- DevTools is life - open it, learn from it
- Test with proper setup - wrap in QueryClientProvider
- Co-locate queries - keep definitions in one place
💬 Let's Connect!
This is the COMPLETE guide - everything in one place!
If this helped you:
- ❤️ Give it a like
- 💬 Comment which concept clicked
- 🔄 Share with friends learning React
- 📚 Bookmark for reference
Questions? Drop them below! I'll answer every single one! 🙌
Made with ❤️, lots of debugging, and 6 months of "why isn't this caching?!" by Rajat
CS Student | React Developer | Still Learning TanStack Query Features
P.S. - My first TanStack Query app had staleTime: 0 everywhere and no error handling. We all start somewhere! 😅
P.P.S. - The DevTools saved me SO many times. Open it. Explore it. Love it! 🛠️
P.P.P.S. - This took 6 months to truly understand. Don't rush. It's worth it! 🚀
Top comments (0)