How to Use TanStack Query in React: A Complete Guide
Table of Contents (Time: ~100 minutes total)
- What is TanStack Query? - 5 min
- The Mental Model Shift - 5 min
- Quick Start: Your First Query - 10 min
- Why Use TanStack Query? - 10 min
- Core Concepts Explained - 15 min
- Basic Usage Patterns - 15 min
- Mutations & Data Updates - 15 min
- Advanced Features - 25 min (includes SSR/Hydration)
- Migration Strategy - 10 min
- Performance Impact - 10 min
What is TanStack Query?
TL;DR: TanStack Query is a smart caching layer between your React UI and your backend that eliminates repetitive fetch logic and automatically keeps your data fresh.
Think of it as:
- 🧠 A memory system for your app
- 🔄 Automatic data synchronization
- ⚡ Instant page loads from cache
- 🎯 Zero boilerplate loading states
In one sentence: Instead of manually fetching data with useEffect and managing loading states, you declare what data you need and TanStack Query handles everything else.
Who this guide is for:
- React developers comfortable with hooks and REST APIs
- Teams dealing with excessive API calls and backend costs
- Apps with loading state boilerplate everywhere
- Anyone wanting significant reduction in API costs
Note: This guide covers TanStack Query v5, the current major version. Some patterns differ from v4 — key differences are called out where relevant.
The Mental Model Shift
Before diving into code, this is the single most important concept to internalize:
❌ Old thinking: "Fetch data when component mounts" ✅ New thinking: "Describe what data I need, the library handles when to fetch"
This shift from imperative to declarative is the key to TanStack Query's power. You stop telling React how and when to fetch data, and instead declare what data a component needs. TanStack Query figures out the rest — caching, deduplication, background refetching, error retries, and stale data management.
Once this clicks, everything else in this guide will feel intuitive.
Quick Start: Your First Query in 2 Minutes
Step 1: Install and Setup (30 seconds)
npm install @tanstack/react-query
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
Step 2: Replace Your useEffect Fetching (90 seconds)
❌ Old way (useEffect + useState):
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => setProducts(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error!</div>;
return <div>{/* render products */}</div>;
}
✅ New way (useQuery):
import { useQuery } from '@tanstack/react-query';
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json())
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error!</div>;
return <div>{/* render data */}</div>;
}
🎉 That's it! You now have:
- ✅ Automatic caching (navigation back = instant load)
- ✅ Background refetching (data stays fresh)
- ✅ No race conditions (old requests canceled)
- ✅ Global state (all components share data)
💡 Quick Test: Navigate to the page, then navigate away and back. The second load should be instant (0ms) with no loading spinner!
Why Use TanStack Query?
The 6 Problems with Traditional Fetching
| Problem | Impact | TanStack Query Solution |
|---|---|---|
| No cache | Re-fetch same data repeatedly | Smart caching with configurable freshness |
| Race conditions | Wrong data displayed | Automatic request cancellation |
| Stale data | Users see outdated info | Background refetching |
| Manual state | Hundreds of lines of boilerplate | Automatic loading/error management |
| Duplicate requests | 3 components = 3 API calls | Request deduplication |
| Prop drilling | Complex state sharing | Global cache accessible anywhere |
Real-World Impact
Before TanStack Query:
- 🔴 User browses 3 products: 21 API calls, 3.4 seconds
- 🔴 Every page navigation: full reload with spinner
- 🔴 Monthly AWS costs: $300
After TanStack Query:
- 🟢 User browses 3 products: 11 API calls (48% reduction), 1.2 seconds
- 🟢 Cached pages: instant (0-50ms)
- 🟢 Monthly AWS costs: $80 (73% savings)
Transparency note: These numbers are based on a mid-size e-commerce app with ~10K monthly users. Your results will vary depending on navigation patterns, cache hit rates, and data volatility. The key takeaway is that caching eliminates redundant requests, and the savings scale with traffic.
See full performance metrics →
Core Concepts Explained
1. Query Keys: Your Cache Address
Analogy: Query keys are like URLs for your cache.
// Different query keys = different cache entries
['products'] // All products
['products', 'featured'] // Only featured products
['product', 123] // Product with ID 123
['user', userId] // User-specific data
Rules:
- Must be an array
- Must be unique for each piece of data
- Include dynamic values in the key:
['product', productId] - Order matters:
['a', 'b'] ≠ ['b', 'a']
⚠️ Common Mistake: Using the same query key for different data causes cache collisions. Always include dynamic values like IDs in your query keys!
💡 Pro Tip: Create key factories to stay organized:
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: string) => [...productKeys.lists(), { filters }] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: number) => [...productKeys.details(), id] as const,
}
2. Stale Time: How Long Data is "Fresh"
Analogy: Like food freshness labels.
staleTime: 5 * 60 * 1000 // Fresh for 5 minutes
What happens:
- Data < staleTime: Use cached data instantly, no refetch
- Data > staleTime: Use cached data immediately, refetch in background
Decision Tree: What staleTime should I use?
├─ Real-time data (stock prices)? → 0-30 seconds
├─ Frequently changing (news feed)? → 1-5 minutes
├─ Moderate updates (product listings)? → 5-15 minutes
└─ Rarely changing (user profile)? → 15-30 minutes
⚠️ Common Mistake: Setting staleTime: 0 (default) means data is always stale, causing constant refetches on every component mount.
3. Cache Time (gcTime): How Long Unused Data Stays in Memory
Analogy: Like a refrigerator — keeps food longer than it's fresh.
gcTime: 5 * 60 * 1000 // Keep in memory for 5 minutes after last use
v5 rename: This was called
cacheTimein v4. It's nowgcTime(garbage collection time) in v5.
What happens:
- Component unmounts → Data marked as "inactive"
- After
gcTimepasses → Data removed from memory (garbage collected) - If component remounts within
gcTime→ Instant cached data
The Relationship: staleTime < gcTime (always)
Example: staleTime: 5 minutes, gcTime: 30 minutes
Timeline:
0 min → Data fetched (fresh)
5 min → Data becomes stale (but still cached)
30 min → Data removed from memory
💡 Pro Tip: Set gcTime at least 2x longer than staleTime to ensure smooth navigation. Example: staleTime: 5min, gcTime: 10-15min.
4. Background Refetching: Keeping Data Fresh
TanStack Query automatically refetches stale data when:
- ✅ Window regains focus (user comes back to tab)
- ✅ Network reconnects
- ✅ Query is mounted (if stale)
- ✅ Manual refetch interval (if configured)
You can control this:
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: true, // Default: true
refetchOnReconnect: true, // Default: true
refetchInterval: 30000, // Poll every 30s (optional)
})
Basic Usage Patterns
Pattern 1: Simple Data Fetching
Basic Version:
import { useQuery } from '@tanstack/react-query';
function Products() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json())
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Production Version (with TypeScript & error handling):
import { useQuery } from '@tanstack/react-query';
interface Product {
id: number;
name: string;
price: number;
}
async function fetchProducts(): Promise<Product[]> {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
function Products() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <LoadingSpinner />;
if (error) {
return (
<ErrorMessage
message={error.message}
onRetry={() => refetch()}
/>
);
}
return (
<div>
<h2>Products ({data.length})</h2>
<ul>
{data.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
</div>
);
}
Pattern 2: Dynamic Query Keys (with Parameters)
When to use: Fetching data based on props/params (product detail, user profile, search results).
function ProductDetail({ productId }: { productId: number }) {
const { data, isLoading } = useQuery({
queryKey: ['product', productId], // ← Include ID in key
queryFn: () => fetch(`/api/products/${productId}`).then(r => r.json()),
staleTime: 10 * 60 * 1000, // 10 minutes
});
if (isLoading) return <div>Loading...</div>;
return <div>{data.name}</div>;
}
💡 Pro Tip: The query key automatically updates when productId changes, triggering a new fetch.
Pattern 3: Conditional Queries (enabled)
When to use: Don't fetch until certain conditions are met (user is logged in, permission granted, dependency loaded).
function UserProfile({ userId }: { userId: number | null }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
enabled: !!userId, // ← Only fetch when userId exists
staleTime: 15 * 60 * 1000,
});
if (!userId) return <div>Please log in</div>;
if (!data) return <div>Loading...</div>;
return <div>Welcome, {data.name}!</div>;
}
Pattern 4: Dependent Queries (wait for one, then fetch another)
When to use: Second query needs data from first query.
function UserPosts({ userId }: { userId: number }) {
// First: Get user details
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
// Second: Get user's posts (only when we have user data)
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetch(`/api/posts?userId=${user.id}`).then(r => r.json()),
enabled: !!user, // ← Wait for user to load
});
if (!user) return <div>Loading user...</div>;
if (!posts) return <div>Loading posts...</div>;
return (
<div>
<h2>{user.name}'s Posts</h2>
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
}
Pattern 5: Parallel Queries
When to use: Fetch multiple independent data sources at once.
function Dashboard() {
const products = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
const categories = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
});
const stats = useQuery({
queryKey: ['stats'],
queryFn: fetchStats,
});
// Check if ALL queries are loading
if (products.isLoading || categories.isLoading || stats.isLoading) {
return <div>Loading dashboard...</div>;
}
return (
<div>
<ProductList data={products.data} />
<CategoryNav data={categories.data} />
<StatsPanel data={stats.data} />
</div>
);
}
Alternative using useQueries:
function Dashboard() {
const results = useQueries({
queries: [
{ queryKey: ['products'], queryFn: fetchProducts },
{ queryKey: ['categories'], queryFn: fetchCategories },
{ queryKey: ['stats'], queryFn: fetchStats },
],
});
const isLoading = results.some(result => result.isLoading);
const [products, categories, stats] = results.map(r => r.data);
if (isLoading) return <div>Loading...</div>;
return <div>{/* render all data */}</div>;
}
Mutations & Data Updates
Understanding Mutations
Decision Tree:
- Reading data? →
useQuery - Changing data? →
useMutation
What mutations do:
- POST, PUT, PATCH, DELETE requests
- Update server state
- Invalidate cached queries (trigger refetch)
- Optimistically update UI (instant feedback)
⚠️ Critical: Always invalidate related queries after mutations! Forgetting this means users see stale data until cache expires.
Pattern 1: Basic Mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddProduct() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newProduct: Product) => {
return fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json());
},
onSuccess: () => {
// Invalidate and refetch products list
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button
onClick={() => mutation.mutate({ name: 'New Product', price: 99 })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Product'}
</button>
);
}
Pattern 2: Optimistic Updates (Instant UI Feedback)
When to use: Make UI feel instant while waiting for server confirmation.
When NOT to use: Operations where the server might legitimately reject the request — payments, inventory checks, permission-dependent actions, or anything where rollback would confuse the user. For these, wait for server confirmation before updating the UI.
function DeleteProduct({ productId }: { productId: number }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (id: number) => {
return fetch(`/api/products/${id}`, { method: 'DELETE' });
},
// Before mutation runs
onMutate: async (deletedId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] });
// Snapshot current data (for rollback)
const previousProducts = queryClient.getQueryData(['products']);
// Optimistically update UI
queryClient.setQueryData(['products'], (old: Product[]) => {
return old.filter(product => product.id !== deletedId);
});
// Return snapshot for rollback
return { previousProducts };
},
// If mutation fails, rollback
onError: (err, deletedId, context) => {
queryClient.setQueryData(['products'], context.previousProducts);
alert('Failed to delete product');
},
// Always refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button onClick={() => mutation.mutate(productId)}>
Delete
</button>
);
}
What happens:
- User clicks "Delete"
- UI updates instantly (product removed)
- Server request sent in background
- If successful: UI already updated, done!
- If failed: Rollback to previous state, show error
Pattern 3: Invalidation Strategies
// Invalidate exact match
queryClient.invalidateQueries({ queryKey: ['products'] });
// Invalidate all product-related queries
queryClient.invalidateQueries({ queryKey: ['products'], exact: false });
// Invalidates: ['products'], ['products', 'featured'], ['product', 123]
// Invalidate multiple query keys
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['products'] }),
queryClient.invalidateQueries({ queryKey: ['categories'] }),
]);
// Invalidate and refetch immediately
queryClient.invalidateQueries({
queryKey: ['products'],
refetchType: 'active' // Only refetch mounted queries
});
💡 Pro Tip: Use key factories for consistent invalidation:
// After updating a product
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) });
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
Pattern 4: Form Submission with Validation
function ProductForm() {
const [name, setName] = useState('');
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (product: Product) => {
return fetch('/api/products', {
method: 'POST',
body: JSON.stringify(product),
headers: { 'Content-Type': 'application/json' },
}).then(async res => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message);
}
return res.json();
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
setName(''); // Reset form
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ name, price: 99 });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="Product name"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Add Product'}
</button>
{mutation.isError && (
<div className="error">{mutation.error.message}</div>
)}
</form>
);
}
Advanced Features
1. Prefetching (Make Navigation Instant)
When to use: You know what user will click next (product links, pagination).
import { useQueryClient } from '@tanstack/react-query';
function ProductLink({ productId }: { productId: number }) {
const queryClient = useQueryClient();
const prefetchProduct = () => {
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetch(`/api/products/${productId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000,
});
};
return (
<Link to={`/products/${productId}`} onMouseEnter={prefetchProduct}>
View Product
</Link>
);
}
Result: When user clicks, data is already cached = instant page load!
2. Infinite Queries (Infinite Scroll / Load More)
When to use: Pagination, infinite scroll, "Load More" buttons.
import { useInfiniteQuery } from '@tanstack/react-query';
interface ProductsPage {
products: Product[];
nextCursor: number | null;
}
function InfiniteProducts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products', 'infinite'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/products?cursor=${pageParam}`);
return res.json() as Promise<ProductsPage>;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
3. Suspense Integration (React 18+)
When to use: Want to use React Suspense boundaries for loading states.
import { useSuspenseQuery } from '@tanstack/react-query';
function ProductDetail({ id }: { id: number }) {
// No isLoading check needed - Suspense handles it
const { data } = useSuspenseQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
});
return <div>{data.name}</div>;
}
// Wrap with Suspense in parent
function ProductPage({ id }: { id: number }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProductDetail id={id} />
</Suspense>
);
}
✅ React 18 Compatible: TanStack Query works seamlessly with React 18's concurrent rendering and Suspense. This makes it ideal for modern frameworks like Next.js App Router, Remix, and React Server Components.
4. Query Retries & Error Handling
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
retry: 3, // Retry 3 times on failure
retryDelay: attemptIndex => {
return Math.min(1000 * 2 ** attemptIndex, 30000); // Exponential backoff
},
staleTime: 5 * 60 * 1000,
})
v5 Note: The
onErrorandonSuccesscallbacks were removed fromuseQueryin v5. Handle errors in the component using theerrorreturn value, or use the globalQueryCachecallbacks for logging/tracking:const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { console.error('Query failed:', error); // Send to error tracking service }, }), });
5. Initial Data (Avoid First Load)
When to use: You already have data from SSR or initial page load.
function ProductDetail({ id, initialData }: Props) {
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
initialData: initialData, // Use this on first render
staleTime: 5 * 60 * 1000, // Mark as fresh for 5 min
});
return <div>{data.name}</div>;
}
6. Placeholder Data (Show Something Immediately)
When to use: Show approximate/cached data while fetching fresh data.
function ProductDetail({ id }: { id: number }) {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
placeholderData: () => {
// Use data from products list as placeholder
const products = queryClient.getQueryData(['products']) as Product[];
return products?.find(p => p.id === id);
},
});
return <div>{data?.name || 'Loading...'}</div>;
}
7. SSR & Hydration (Next.js, Remix, Server Components)
When to use: Server-side rendering or using React Server Components.
The Problem: On SSR, you fetch data on the server, but the client doesn't know about it and refetches unnecessarily.
The Solution: Prefetch on the server, dehydrate the cache, and hydrate it on the client so queries resolve instantly without redundant network requests.
Option 1: Using initialData (Simple)
Best for one or two queries on a page.
// Next.js App Router — Server Component
async function ProductPage({ params }: { params: { id: string } }) {
// Fetch on server
const serverData = await fetch(
`https://api.example.com/products/${params.id}`
).then(r => r.json());
return <ProductClient id={Number(params.id)} initialData={serverData} />;
}
// Client component
'use client';
function ProductClient({ id, initialData }: Props) {
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
initialData: initialData, // Use server data on first render
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
});
return <div>{data.name}</div>;
}
Option 2: Using Dehydration/Hydration (Recommended for Complex Apps)
This is the v5 recommended pattern. It lets you prefetch multiple queries on the server and hydrate them all at once on the client.
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/products/page.tsx — Server Component
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
export default async function ProductsPage() {
const queryClient = new QueryClient();
// Prefetch on the server
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}
// components/ProductList.tsx — Client Component
'use client';
import { useQuery } from '@tanstack/react-query';
export function ProductList() {
// This query is already prefetched — no loading state on initial render
const { data } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<ul>
{data?.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Benefits:
- ✅ No loading state on initial page load
- ✅ Data available immediately on client
- ✅ Automatic refetching when stale
- ✅ Works with streaming SSR
- ✅ Scales to any number of queries per page
💡 Pro Tip: For Next.js App Router, use initialData for simple single-query pages. For pages with multiple queries or nested components that each need data, use the HydrationBoundary + dehydrate pattern — it's more composable and avoids prop drilling server data.
Resources:
Migration Strategy
Phase 1: Setup (15 minutes)
# 1. Install
npm install @tanstack/react-query @tanstack/react-query-devtools
// 2. Wrap app
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min default
gcTime: 10 * 60 * 1000, // 10 min default
retry: 1,
refetchOnWindowFocus: false, // Disable for testing
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Phase 2: Migrate High-Traffic Pages (1-2 hours per page)
Priority order:
- ✅ Home page / Dashboard (biggest impact)
- ✅ List pages (product list, user list)
- ✅ Detail pages (with prefetching)
- ✅ Forms with mutations
- ✅ Everything else
Migration checklist per page:
// Before
function OldProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then(setProducts).finally(() => setLoading(false));
}, []);
// ...
}
// After
function NewProductList() {
const { data: products, isLoading } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000,
});
// ...
}
Checklist:
- ✅ Remove
useState - ✅ Remove
useEffect - ✅ Add
useQuery - ✅ Test loading/error states
- ✅ Verify caching works (navigate away and back)
Phase 3: Add Mutations (30 min per form)
// Before
const handleSubmit = async (data) => {
setLoading(true);
try {
await createProduct(data);
await refetchProducts(); // Manual refetch
} finally {
setLoading(false);
}
};
// After
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
const handleSubmit = (data) => {
mutation.mutate(data);
};
Phase 4: Optimize (1-2 hours)
- Add prefetching to links
- Tune stale times based on data update frequency
- Add optimistic updates to critical interactions (where rollback is unlikely)
- Implement
placeholderDatafor better UX - Remove old data fetching code
Measuring Success
Before migration, track:
const baseline = {
apiCallsPerSession: 45,
avgLoadTime: 1200, // ms
monthlyAPICalls: 450000,
monthlyCost: 300, // USD
};
After migration, compare:
const after = {
apiCallsPerSession: 12,
avgLoadTime: 300,
monthlyAPICalls: 120000,
monthlyCost: 80,
};
Performance Impact
Real Production Metrics
Scenario: E-commerce site, ~10,000 monthly users
| Metric | Before | After | Improvement |
|---|---|---|---|
| Page Load Time | 1.2s | 0.3s | 75% faster |
| API Calls/Session | 45 | 12 | 73% reduction |
| Monthly API Calls | 450,000 | 120,000 | 330,000 fewer |
| AWS Costs | $300/mo | $80/mo | $220 saved/mo |
These are illustrative numbers from a single mid-size app. Your mileage will vary based on navigation depth, cache hit rates, and how much redundant fetching your current code does. The pattern holds: caching reduces redundant calls, and the more users navigate, the bigger the savings.
Cost Savings Calculation
// AWS API Gateway: ~$3.50 per million requests
// Lambda: ~$0.20 per million requests
Before:
- 450,000 API calls/month
- Gateway: $1.58 + Lambda costs
- Total: ~$300/month
After:
- 120,000 API calls/month
- Gateway: $0.42 + Lambda costs
- Total: ~$80/month
Annual Savings: $2,640
Implementation Time: 2 weeks
ROI: Immediate (first month)
User Experience Impact
Navigation flow example:
| Action | Before (no cache) | After (with cache) |
|---|---|---|
| View home page | 5 API calls (1s) | 5 API calls (1s) |
| Click product | 3 API calls (800ms) | 3 API calls (800ms) |
| Click back | 5 API calls (1s) 😢 | 0 API calls (instant!) 🎉 |
| Click another product | 3 API calls (800ms) | 0-3 calls (instant if prefetched) |
| Click back | 5 API calls (1s) 😢 | 0 API calls (instant!) 🎉 |
Total: 21 API calls → 8-11 API calls = 48-62% reduction
Troubleshooting
"Why isn't my data refetching?"
Check:
- Is
staleTimetoo long? (data not marked stale yet) - Is
refetchOnWindowFocusdisabled? - Is the query
enabled: false? - Did you forget to invalidate after mutation?
// Debug stale time
const { data, isStale } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000,
});
console.log('Is data stale?', isStale);
"Why do I see loading state when data should be cached?"
Possible causes:
- Query key changed (new cache entry)
- Cache expired (
gcTimepassed) - Data was invalidated
- Different component using different query key
// Check cache contents
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
const cachedData = queryClient.getQueryData(['products']);
console.log('Cached data:', cachedData);
"How do I know if caching is working?"
Use DevTools:
- Open React Query DevTools (bottom-left icon)
- Navigate to a page
- Navigate away
- Navigate back
- Check DevTools:
- ✅ Green = Fresh (from cache)
- 🟡 Yellow = Stale (from cache, refetching)
- 🔴 Red = Error
💡 Pro Tip: Open your browser's Network tab alongside React Query DevTools. If you navigate back to a page and see NO network requests but the page loads instantly, caching is working perfectly!
Or add global logging (v5 pattern):
import { QueryCache } from '@tanstack/react-query';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onSuccess: () => console.log('✅ Query success'),
onError: () => console.log('❌ Query error'),
}),
});
"Data is stale but not refetching"
Check refetch settings:
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000,
refetchOnMount: true, // Refetch when component mounts
refetchOnWindowFocus: true, // Refetch when window focused
refetchOnReconnect: true, // Refetch when network reconnects
})
Best Practices Summary
Query Keys
- ✅ DO: Use arrays:
['products'] - ✅ DO: Include dynamic values:
['product', id] - ✅ DO: Use key factories for consistency
- ✅ DO: Make keys unique per data piece
- ❌ DON'T: Use strings:
'products' - ❌ DON'T: Forget dynamic values in key
- ❌ DON'T: Use same key for different data
Stale Time
- ✅ DO: Set based on data update frequency
- ✅ DO: Use longer times for static data (15-30 min)
- ✅ DO: Use shorter times for live data (0-60 sec)
- ✅ DO: Balance performance vs freshness
- ❌ DON'T: Leave as default (0) for all queries
- ❌ DON'T: Set too long for rapidly changing data
- ❌ DON'T: Set same value for all queries
Mutations
- ✅ DO: Invalidate related queries
- ✅ DO: Use optimistic updates for low-risk, instant UX
- ✅ DO: Handle errors gracefully
- ✅ DO: Disable buttons while pending
- ❌ DON'T: Forget to invalidate queries
- ❌ DON'T: Over-invalidate (refetch everything)
- ❌ DON'T: Ignore error states
- ❌ DON'T: Use optimistic updates for server-validated operations (payments, permissions)
Performance
- ✅ DO: Prefetch data on hover/prediction
- ✅ DO: Use
placeholderDatafor instant display - ✅ DO: Monitor cache hit rates
- ✅ DO: Tune stale times based on metrics
- ❌ DON'T: Over-fetch (too aggressive refetching)
- ❌ DON'T: Under-cache (
staleTimetoo short) - ❌ DON'T: Ignore DevTools insights
- ❌ DON'T: Skip performance tracking
Quick Reference: Common Patterns
// ────────────────────────────────────────────────────────
// BASIC QUERY
// ────────────────────────────────────────────────────────
const { data, isLoading, error } = useQuery({
queryKey: ['key'],
queryFn: fetchFunction,
})
// ────────────────────────────────────────────────────────
// QUERY WITH PARAMETERS
// ────────────────────────────────────────────────────────
const { data } = useQuery({
queryKey: ['key', param],
queryFn: () => fetchFunction(param),
})
// ────────────────────────────────────────────────────────
// CONDITIONAL QUERY
// ────────────────────────────────────────────────────────
const { data } = useQuery({
queryKey: ['key'],
queryFn: fetchFunction,
enabled: !!condition,
})
// ────────────────────────────────────────────────────────
// MUTATION
// ────────────────────────────────────────────────────────
const mutation = useMutation({
mutationFn: postFunction,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['key'] })
},
})
// ────────────────────────────────────────────────────────
// OPTIMISTIC UPDATE
// ────────────────────────────────────────────────────────
const mutation = useMutation({
mutationFn: updateFunction,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['key'] })
const previous = queryClient.getQueryData(['key'])
queryClient.setQueryData(['key'], newData)
return { previous }
},
onError: (err, newData, context) => {
queryClient.setQueryData(['key'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['key'] })
},
})
// ────────────────────────────────────────────────────────
// PREFETCH
// ────────────────────────────────────────────────────────
queryClient.prefetchQuery({
queryKey: ['key'],
queryFn: fetchFunction,
})
// ────────────────────────────────────────────────────────
// INFINITE QUERY
// ────────────────────────────────────────────────────────
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['key'],
queryFn: ({ pageParam }) => fetchPage(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
})
Conclusion
TanStack Query transforms React apps from stateless UIs into data-smart applications that:
- 🧠 Remember what they fetched
- 🔄 Sync automatically
- ⚡ Load instantly
- 💰 Cost less to run
The Core Shift
From: "Fetch data when component mounts" To: "Describe what data I need, the library handles the rest"
Key Takeaways
- Replace
useEffectwithuseQuery→ Eliminate boilerplate - Use query keys properly → Cache isolation
- Tune
staleTime→ Balance freshness vs performance - Invalidate strategically → Keep data fresh
- Add optimistic updates → Instant UX (for appropriate operations)
- Prefetch intelligently → Feel instant
- Measure results → Prove the value
Next Steps
- ✅ Install TanStack Query today
- ✅ Migrate one high-traffic page
- ✅ Measure API reduction & load times
- ✅ Add to 2-3 more pages over next week
- ✅ Share results with team
- ✅ Optimize stale times based on metrics
Resources
- 📚 Official TanStack Query Docs
- 🎥 TanStack Query in 100 Seconds
- 🛠️ React Query DevTools
- 📖 Query Key Patterns
- 🎓 Video Course: Mastering React Query
- 💬 TanStack Discord Community
Appendix: Full E-commerce Example
// ═══════════════════════════════════════════════════════
// PRODUCT LIST WITH PREFETCHING
// ═══════════════════════════════════════════════════════
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
interface Product {
id: number;
name: string;
price: number;
imageUrl: string;
}
async function fetchProducts(): Promise<Product[]> {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
async function fetchProduct(id: number): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error('Failed to fetch product');
return res.json();
}
export function ProductList() {
const queryClient = useQueryClient();
const { data: products, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
});
const prefetchProduct = (productId: number) => {
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 10 * 60 * 1000, // Fresh for 10 minutes
});
};
if (isLoading) {
return <div>Loading products...</div>;
}
if (error) {
return (
<div>
<p>Failed to load products: {error.message}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
return (
<div>
{products.map(product => (
<Link
key={product.id}
to={`/products/${product.id}`}
onMouseEnter={() => prefetchProduct(product.id)}
>
<div>{product.name}</div>
<div>${product.price}</div>
</Link>
))}
</div>
);
}
// ═══════════════════════════════════════════════════════
// PRODUCT DETAIL
// ═══════════════════════════════════════════════════════
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
export function ProductDetail() {
const { id } = useParams<{ id: string }>();
const productId = parseInt(id!);
const queryClient = useQueryClient();
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 10 * 60 * 1000,
placeholderData: () => {
// Try to get from products list cache
const products = queryClient.getQueryData(['products']) as Product[];
return products?.find(p => p.id === productId);
},
});
if (isLoading && !product) {
return <div>Loading product...</div>;
}
if (!product) {
return <div>Product not found</div>;
}
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
);
}
// ═══════════════════════════════════════════════════════
// ADD TO CART (MUTATION WITH OPTIMISTIC UPDATE)
// ═══════════════════════════════════════════════════════
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
interface CartItem {
productId: number;
quantity: number;
}
async function addToCart(productId: number): Promise<CartItem> {
const res = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity: 1 }),
});
if (!res.ok) throw new Error('Failed to add to cart');
return res.json();
}
function AddToCartButton({ productId }: { productId: number }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => addToCart(productId),
onMutate: async () => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['cart'] });
// Snapshot current cart
const previousCart = queryClient.getQueryData(['cart']);
// Optimistically update UI
queryClient.setQueryData(['cart'], (old: CartItem[] = []) => {
const existingItem = old.find(item => item.productId === productId);
if (existingItem) {
return old.map(item =>
item.productId === productId
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...old, { productId, quantity: 1 }];
});
return { previousCart };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['cart'], context?.previousCart);
alert('Failed to add to cart. Please try again.');
},
onSettled: () => {
// Always refetch cart to sync with server
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
return (
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="btn-primary"
>
{mutation.isPending ? 'Adding...' : 'Add to Cart'}
</button>
);
}




