🔄 Lesson 8.4: React Query (TanStack Query)
Welcome to the data fetching revolution! React Query (now TanStack Query) has fundamentally changed how we think about server state in React applications. If you've ever struggled with loading states, caching, background refetching, or synchronizing server data, React Query is the solution you've been looking for. In this comprehensive lesson, you'll learn how to use React Query to build applications with seamless data fetching, automatic caching, and incredible developer experience.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand the difference between server state and client state
- Install and configure React Query in a TypeScript project
- Use queries to fetch and cache data automatically
- Implement mutations for creating, updating, and deleting data
- Handle loading, error, and success states elegantly
- Configure automatic refetching and cache invalidation
- Implement optimistic updates for instant UI feedback
- Type queries and mutations properly with TypeScript
- Use React Query DevTools for debugging
- Apply React Query best practices and patterns
Estimated Time: 70-85 minutes
Prerequisites: Lessons 8.1-8.3 (State Management, Zustand, Redux Toolkit), Modules 1-4
📑 In This Lesson
🤔 Understanding Server State vs Client State
React Query solves a fundamental problem: managing server state is fundamentally different from managing client state, yet we often treat them the same way.
What is Server State?
📖 Definition
Server state is data that lives on a remote server and is fetched over the network. It's data you don't own - it can change without your knowledge, be updated by other users, and requires synchronization between the server and your UI. React Query (now TanStack Query) is a powerful data fetching library that manages server state automatically with caching, background updates, and optimistic updates.
Server State vs Client State
| Server State | Client State |
|---|---|
| Fetched from API/server | Created and managed locally |
| Shared across users | Unique to one user/session |
| Can become stale | Always current |
| Needs caching strategy | Direct state management |
| Requires synchronization | No synchronization needed |
| Examples: User data, posts, products | Examples: Modal open, theme, form inputs |
The Problem with Traditional Approaches
Before React Query, managing server state typically looked like this:
// ❌ The old way - so much boilerplate!
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setIsLoading(false);
})
.catch(err => {
setError(err.message);
setIsLoading(false);
});
}, []); // When should we refetch?
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
⚠️ Problems with Manual Fetching
- Boilerplate: Loading states, error states, success states
- No Caching: Same data fetched multiple times
- No Background Updates: Data goes stale
- Race Conditions: Multiple requests can conflict
- No Deduplication: Multiple components = multiple requests
- Manual Refetching: When should we refetch? On focus? On interval?
- Memory Leaks: Updating state after unmount
How React Query Solves These Problems
With React Query, the same component becomes:
// ✅ The React Query way - simple and powerful!
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
✅ React Query Benefits
Out of the box, React Query provides:
- Automatic Caching: Data is cached and shared across components
- Background Refetching: Keeps data fresh automatically
- Window Focus Refetching: Updates when user returns to tab
- Request Deduplication: Multiple requests = one network call
- Automatic Retries: Failed requests retry automatically
- Garbage Collection: Old data is cleaned up automatically
- Optimistic Updates: Update UI before server responds
- Pagination & Infinite Scroll: Built-in support
- DevTools: Visualize queries and cache
When to Use React Query
Use React Query for:
- ✅ Fetching data from REST APIs
- ✅ GraphQL queries (with plugins)
- ✅ Data that can become stale
- ✅ Data shared across components
- ✅ CRUD operations
- ✅ Pagination and infinite scroll
Don't use React Query for:
- ❌ Local UI state (modals, themes, forms)
- ❌ One-time configuration fetches
- ❌ Data you don't need to cache
- ❌ Simple, infrequent API calls
⚙️ Installation and Setup
Let's get React Query set up in your React TypeScript project.
Installing React Query
# Install React Query v5 (TanStack Query)
npm install @tanstack/react-query
# Optional: Install DevTools for debugging
npm install @tanstack/react-query-devtools
💡 Version Note
React Query was rebranded as TanStack Query starting with v4. The package is now @tanstack/react-query. All functionality works the same, just with a new namespace. We'll use the latest v5 in this lesson.
Setting Up QueryClient
Create and configure a QueryClient with default options:
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 10, // 10 minutes
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: 1,
},
},
});
// Explanation:
// - staleTime: How long until data is considered stale
// - cacheTime: How long to keep unused data in cache
// - refetchOnWindowFocus: Refetch when user returns to window
// - refetchOnReconnect: Refetch when internet reconnects
// - retry: Number of retry attempts on failure
Wrapping Your App
Provide the QueryClient to your entire application:
// src/main.tsx (or App.tsx)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* DevTools - only appears in development */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);
✅ React Query DevTools
The DevTools provide an amazing debugging experience:
- See all queries and their current state
- Visualize query cache
- Manually trigger refetches
- Inspect query data and errors
- View query timelines
- Automatically tree-shaken from production builds
Project Structure
Organize your React Query code:
src/
lib/
queryClient.ts // QueryClient configuration
api/
users.ts // User-related API functions
posts.ts // Post-related API functions
hooks/
queries/
useUsers.ts // User query hooks
usePosts.ts // Post query hooks
mutations/
useCreateUser.ts // User mutation hooks
useUpdatePost.ts // Post mutation hooks
components/
users/
UserList.tsx // Uses user queries
CreateUserForm.tsx // Uses user mutations
Creating API Functions
Separate your API logic from components:
// src/api/users.ts
export interface User {
id: string;
name: string;
email: string;
}
// GET all users
export async function getUsers(): Promise<User[]> {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}
// GET single user
export async function getUser(id: string): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
// POST create user
export async function createUser(userData: Omit<User, 'id'>): Promise<User> {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
}
// PATCH update user
export async function updateUser(
id: string,
updates: Partial<User>
): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error('Failed to update user');
}
return response.json();
}
// DELETE user
export async function deleteUser(id: string): Promise<void> {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
}
⚠️ Error Handling
Always check response.ok and throw errors for failed requests. React Query will catch these errors and expose them through the error property in your hooks.
📥 Queries: Fetching Data
Queries are the core of React Query. They fetch data, cache it, and keep it synchronized with the server.
Basic useQuery
// src/components/UserList.tsx
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../api/users';
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: getUsers
});
if (isLoading) {
return <div>Loading users...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<ul>
{data?.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
Understanding Query States
React Query provides detailed state information:
function UserList() {
const {
data, // The data returned from queryFn
error, // Error object if query failed
isLoading, // true on first load (no cached data)
isFetching, // true whenever fetching (even if cached data exists)
isError, // true if query errored
isSuccess, // true if query succeeded
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
refetch, // Function to manually refetch
} = useQuery({
queryKey: ['users'],
queryFn: getUsers
});
// Different ways to check loading
if (isLoading) {
return <div>Loading for the first time...</div>;
}
return (
<div>
{isFetching && <div>Updating...</div>}
{/* Render data */}
</div>
);
}
Query with Parameters
// src/components/UserProfile.tsx
import { useQuery } from '@tanstack/react-query';
import { getUser } from '../api/users';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId], // Include params in key!
queryFn: () => getUser(userId),
enabled: !!userId, // Only run if userId exists
});
if (isLoading) return <div>Loading user...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
💡 The enabled Option
Use enabled to conditionally execute queries:
- Wait for required data before fetching
- Disable queries based on user permissions
- Implement dependent queries
- Prevent unnecessary requests
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
enabled: !!userId && hasPermission
});
TypeScript Integration
// Strongly typed query
const { data } = useQuery<User[], Error>({
queryKey: ['users'],
queryFn: getUsers
});
// data is typed as User[] | undefined
// error is typed as Error | null
// With type inference (recommended)
const { data, error } = useQuery({
queryKey: ['users'],
queryFn: getUsers // Return type inferred as User[]
});
// TypeScript infers types from getUsers function
Query Options
const { data } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
// Caching
staleTime: 1000 * 60 * 5, // 5 minutes until stale
cacheTime: 1000 * 60 * 10, // 10 minutes in cache (deprecated in v5, use gcTime)
gcTime: 1000 * 60 * 10, // Garbage collection time (v5+)
// Refetching
refetchOnMount: true, // Refetch on component mount if stale
refetchOnWindowFocus: true, // Refetch when window regains focus
refetchOnReconnect: true, // Refetch when internet reconnects
refetchInterval: false, // Refetch every X ms (or false to disable)
refetchIntervalInBackground: false, // Refetch even when window not focused
// Retry
retry: 3, // Retry failed requests 3 times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Other
enabled: true, // Enable/disable query
placeholderData: [], // Show while loading (doesn't go to cache)
initialData: [], // Initial data (goes to cache)
// Callbacks
onSuccess: (data) => {
console.log('Query succeeded:', data);
},
onError: (error) => {
console.error('Query failed:', error);
},
onSettled: (data, error) => {
console.log('Query completed');
}
});
✅ Query Best Practices
- Always include all dependencies in
queryKey - Keep query functions pure and predictable
- Use
enabledfor conditional fetching - Set appropriate
staleTimebased on data volatility - Extract query logic into custom hooks for reusability
- Let TypeScript infer types from your API functions
🔑 Query Keys and Caching
Query keys are the backbone of React Query's caching system. Understanding them is crucial for effective data management.
How Query Keys Work
📖 Query Keys
Query keys uniquely identify queries in the cache. They can be strings or arrays, and React Query compares them deeply. If two queries have the same key, they share the same cached data.
// Simple string key
useQuery({
queryKey: ['users'],
queryFn: getUsers
});
// Array with parameters
useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId)
});
// Complex keys with multiple parameters
useQuery({
queryKey: ['users', { status: 'active', page: 1 }],
queryFn: () => getUsers({ status: 'active', page: 1 })
});
// Keys are compared deeply
['users', { page: 1, status: 'active' }]
===
['users', { status: 'active', page: 1 }] // true!
Query Key Patterns
// ✅ Good: Hierarchical structure
['users'] // All users
['users', userId] // Specific user
['users', userId, 'posts'] // User's posts
['users', userId, 'posts', postId] // Specific post
// ✅ Good: With filters/params
['users', { status: 'active' }]
['posts', { page: 1, limit: 10 }]
['products', { category: 'electronics', sort: 'price' }]
// ❌ Bad: Inconsistent structure
['users']
['userById', userId] // Should be ['users', userId]
['getUserPosts', userId] // Should be ['users', userId, 'posts']
Query Key Factory
Create a centralized key factory for consistency:
// src/lib/queryKeys.ts
export const queryKeys = {
// Users
users: {
all: ['users'] as const,
lists: () => [...queryKeys.users.all, 'list'] as const,
list: (filters: string) =>
[...queryKeys.users.lists(), { filters }] as const,
details: () => [...queryKeys.users.all, 'detail'] as const,
detail: (id: string) =>
[...queryKeys.users.details(), id] as const,
},
// Posts
posts: {
all: ['posts'] as const,
lists: () => [...queryKeys.posts.all, 'list'] as const,
list: (filters: string) =>
[...queryKeys.posts.lists(), { filters }] as const,
details: () => [...queryKeys.posts.all, 'detail'] as const,
detail: (id: string) =>
[...queryKeys.posts.details(), id] as const,
},
};
// Usage
useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => getUser(userId)
});
useQuery({
queryKey: queryKeys.posts.list('published'),
queryFn: () => getPosts({ status: 'published' })
});
✅ Benefits of Query Key Factory
- Consistency: Same structure everywhere
- Type Safety: TypeScript autocomplete
- Maintainability: Change keys in one place
- Invalidation: Easy to target specific queries
- Documentation: Self-documenting API
Understanding the Cache
Stale Time vs Cache Time
useQuery({
queryKey: ['users'],
queryFn: getUsers,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10 // 10 minutes (was cacheTime in v4)
});
// Timeline:
// t=0: Fetch data, cache it as "fresh"
// t=5m: Data becomes "stale" but still in cache
// - On mount, return stale data + refetch in background
// t=10m: Data removed from cache (garbage collected)
// - On mount, show loading + fetch fresh data
| staleTime | gcTime (cacheTime) |
|---|---|
| How long data is considered "fresh" | How long inactive data stays in cache |
| Fresh data won't refetch | After gcTime, data is removed |
| Default: 0 (immediately stale) | Default: 5 minutes |
| Use for data that changes slowly | Use to control memory usage |
💡 Choosing staleTime
Set staleTime based on how often your data changes:
- 0ms (default): Real-time data, social feeds
- 30s - 1min: Live dashboards, scores
- 5-10min: User profiles, settings
- 1hour+: Rarely changing data (countries, categories)
- Infinity: Static data (won't refetch until invalidated)
🔧 Mutations: Updating Data
While queries are for reading data, mutations are for creating, updating, or deleting data. React Query's useMutation hook provides a powerful abstraction for modifying server state with automatic cache updates and optimistic UI patterns.
📖 Definition
Mutation: A side effect that changes data on the server (POST, PUT, PATCH, DELETE requests). Mutations trigger cache invalidation and refetching to keep data synchronized.
Basic Mutation Example
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserData {
name: string;
email: string;
}
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
// API function
const createUser = async (userData: CreateUserData): Promise<User> => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
};
// Component
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data) => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
console.log('User created:', data);
},
onError: (error) => {
console.error('Error creating user:', error);
}
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && (
<div className="error">
Error: {mutation.error.message}
</div>
)}
{mutation.isSuccess && (
<div className="success">
User created successfully!
</div>
)}
</form>
);
}
Mutation States
const mutation = useMutation({ mutationFn: createUser });
// Access mutation states
mutation.isPending; // true while request is in flight
mutation.isError; // true if mutation failed
mutation.isSuccess; // true if mutation succeeded
mutation.isIdle; // true if mutation hasn't been called yet
mutation.error; // Error object if failed
mutation.data; // Response data if successful
mutation.status; // 'idle' | 'pending' | 'error' | 'success'
// Call the mutation
mutation.mutate(userData);
// Or use async version with Promise
mutation.mutateAsync(userData)
.then(data => console.log('Success:', data))
.catch(error => console.error('Error:', error));
Update and Delete Mutations
// Update mutation
const updateUser = async ({ id, data }: {
id: string;
data: Partial<User>
}): Promise<User> => {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
};
const updateMutation = useMutation({
mutationFn: updateUser,
onSuccess: (data, variables) => {
// Invalidate specific user and list
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['users', variables.id] });
}
});
// Delete mutation
const deleteUser = async (userId: string): Promise<void> => {
await fetch(`/api/users/${userId}`, {
method: 'DELETE'
});
};
const deleteMutation = useMutation({
mutationFn: deleteUser,
onSuccess: (_, deletedId) => {
// Remove from cache
queryClient.setQueryData<User[]>(['users'], (old) =>
old?.filter(user => user.id !== deletedId)
);
}
});
// Usage
<button onClick={() => updateMutation.mutate({
id: user.id,
data: { name: 'New Name' }
})}>
Update
</button>
<button onClick={() => deleteMutation.mutate(user.id)}>
Delete
</button>
✅ Mutation Best Practices
- Always invalidate affected queries in
onSuccess - Use mutateAsync when you need Promise-based flow
- Handle errors gracefully with onError callbacks
- Show loading states using isPending
- Disable submit buttons during mutations
- Reset mutation state when appropriate with
mutation.reset()
Mutation with Variables
// Access mutation variables in callbacks
const mutation = useMutation({
mutationFn: createUser,
onMutate: async (variables) => {
// Called before mutation function
console.log('About to create:', variables);
// Can return context for rollback
return { timestamp: Date.now() };
},
onSuccess: (data, variables, context) => {
console.log('Created:', data);
console.log('With input:', variables);
console.log('Context:', context);
},
onError: (error, variables, context) => {
console.error('Failed to create:', variables);
console.log('Rollback context:', context);
},
onSettled: (data, error, variables, context) => {
// Called whether success or error
console.log('Mutation finished');
}
});
// All callbacks receive:
// - data: response from mutationFn (success only)
// - error: error object (error only)
// - variables: input passed to mutate()
// - context: value returned from onMutate
🔄 Cache Invalidation and Refetching
One of React Query's superpowers is intelligent cache management. Understanding when and how to invalidate queries ensures your UI always displays the most current data without unnecessary network requests.
Query Invalidation Strategies
import { useQueryClient } from '@tanstack/react-query';
function MyComponent() {
const queryClient = useQueryClient();
// 1. Invalidate exact query
queryClient.invalidateQueries({
queryKey: ['users', '123']
});
// 2. Invalidate all queries starting with key
queryClient.invalidateQueries({
queryKey: ['users']
});
// Invalidates: ['users'], ['users', '123'], ['users', 'list'], etc.
// 3. Invalidate with predicate function
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'users' &&
query.state.data !== undefined;
}
});
// 4. Invalidate multiple query keys
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
// 5. Invalidate and refetch immediately
await queryClient.invalidateQueries({
queryKey: ['users'],
refetchType: 'active' // 'active' | 'inactive' | 'all' | 'none'
});
}
Manual Refetching
function UserProfile({ userId }: { userId: string }) {
const { data, refetch, isRefetching } = useQuery({
queryKey: ['users', userId],
queryFn: () => getUser(userId)
});
return (
<div>
<h2>{data?.name}</h2>
<button
onClick={() => refetch()}
disabled={isRefetching}
>
{isRefetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
);
}
// Refetch from outside component
const queryClient = useQueryClient();
// Refetch specific query
queryClient.refetchQueries({ queryKey: ['users', '123'] });
// Refetch all queries with key prefix
queryClient.refetchQueries({ queryKey: ['users'] });
// Refetch all active queries
queryClient.refetchQueries({ type: 'active' });
Setting Query Data Directly
const queryClient = useQueryClient();
// 1. Set data directly (overwrite)
queryClient.setQueryData<User>(['users', '123'], {
id: '123',
name: 'John Doe',
email: 'john@example.com'
});
// 2. Update data with function (merge)
queryClient.setQueryData<User>(['users', '123'], (oldData) => {
if (!oldData) return oldData;
return { ...oldData, name: 'Updated Name' };
});
// 3. Update list after creating item
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// Add new user to cached list
queryClient.setQueryData<User[]>(['users'], (old) => {
return old ? [...old, newUser] : [newUser];
});
}
});
// 4. Update list after deleting item
const deleteMutation = useMutation({
mutationFn: deleteUser,
onSuccess: (_, deletedId) => {
// Remove user from cached list
queryClient.setQueryData<User[]>(['users'], (old) => {
return old?.filter(user => user.id !== deletedId);
});
}
});
💡 When to Use Each Strategy
| Strategy | When to Use |
|---|---|
| invalidateQueries | Default choice - marks stale and refetches active queries |
| setQueryData | When you have the new data and want instant updates |
| refetchQueries | Force immediate refetch regardless of stale status |
| removeQueries | Clear cache entirely (logout, data no longer valid) |
Removing Queries from Cache
// Remove specific query
queryClient.removeQueries({ queryKey: ['users', '123'] });
// Remove all queries with prefix
queryClient.removeQueries({ queryKey: ['users'] });
// Clear entire cache
queryClient.clear();
// Example: Clear user data on logout
const logout = () => {
// Clear all user-related queries
queryClient.removeQueries({ queryKey: ['users'] });
queryClient.removeQueries({ queryKey: ['profile'] });
// Or clear everything
queryClient.clear();
// Then navigate to login
navigate('/login');
};
⚠️ Common Pitfall: Over-Invalidation
Invalidating too broadly can cause unnecessary refetches:
// ❌ BAD - Invalidates ALL queries
queryClient.invalidateQueries();
// ❌ BAD - Invalidates all user queries after updating one
queryClient.invalidateQueries({ queryKey: ['users'] });
// ✅ GOOD - Only invalidate what changed
queryClient.invalidateQueries({ queryKey: ['users', userId] });
⚡ Optimistic Updates
Optimistic updates make your app feel instant by updating the UI immediately, before waiting for the server response. If the server request fails, React Query automatically rolls back to the previous state.
📖 Definition
Optimistic Update: Updating the UI immediately based on the expected result of an operation, before receiving server confirmation. Provides instant feedback and makes apps feel faster.
Basic Optimistic Update
interface Todo {
id: string;
title: string;
completed: boolean;
}
const toggleTodo = async (id: string): Promise<Todo> => {
const response = await fetch(`/api/todos/${id}/toggle`, {
method: 'PATCH'
});
return response.json();
};
function TodoList() {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: toggleTodo,
// Before mutation starts
onMutate: async (todoId) => {
// Cancel outgoing refetches (so they don't overwrite optimistic update)
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update the cache
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
return old?.map(todo =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo
);
});
// Return context with snapshot
return { previousTodos };
},
// On error, rollback
onError: (error, variables, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
alert('Failed to update todo. Please try again.');
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
return (
<ul>
{todos?.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate(todo.id)}
/>
{todo.title}
</li>
))}
</ul>
);
}
Optimistic Create
const createTodo = async (newTodo: { title: string }): Promise<Todo> => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo)
});
return response.json();
};
const createMutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Create optimistic todo with temporary ID
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`, // Temporary ID
title: newTodo.title,
completed: false
};
// Add optimistic todo to cache
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
return old ? [...old, optimisticTodo] : [optimisticTodo];
});
return { previousTodos, optimisticTodo };
},
onSuccess: (newTodo, variables, context) => {
// Replace temp todo with real one from server
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
return old?.map(todo =>
todo.id === context.optimisticTodo.id ? newTodo : todo
);
});
},
onError: (error, variables, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
Optimistic Delete
const deleteTodo = async (id: string): Promise<void> => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
};
const deleteMutation = useMutation({
mutationFn: deleteTodo,
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Remove todo optimistically
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
return old?.filter(todo => todo.id !== todoId);
});
return { previousTodos };
},
onError: (error, variables, context) => {
// Restore deleted todo on error
queryClient.setQueryData(['todos'], context?.previousTodos);
alert('Failed to delete todo');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
✅ Optimistic Update Checklist
- Cancel queries: Prevent refetches from overwriting optimistic state
- Snapshot previous state: Save current data for rollback
- Update cache optimistically: Show expected result immediately
- Return context: Pass snapshot to error/success handlers
- Handle errors: Rollback to previous state on failure
- Invalidate on settled: Sync with server after success or error
Complex Optimistic Update: Like Button
interface Post {
id: string;
title: string;
likes: number;
likedBy: string[]; // Array of user IDs
}
const toggleLike = async ({
postId,
userId
}: {
postId: string;
userId: string
}): Promise<Post> => {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
return response.json();
};
function LikeButton({ post, userId }: { post: Post; userId: string }) {
const queryClient = useQueryClient();
const isLiked = post.likedBy.includes(userId);
const likeMutation = useMutation({
mutationFn: toggleLike,
onMutate: async ({ postId, userId }) => {
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
const previousPost = queryClient.getQueryData<Post>(['posts', postId]);
queryClient.setQueryData<Post>(['posts', postId], (old) => {
if (!old) return old;
const isCurrentlyLiked = old.likedBy.includes(userId);
return {
...old,
likes: isCurrentlyLiked ? old.likes - 1 : old.likes + 1,
likedBy: isCurrentlyLiked
? old.likedBy.filter(id => id !== userId)
: [...old.likedBy, userId]
};
});
return { previousPost };
},
onError: (error, variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['posts', variables.postId], context.previousPost);
}
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: ['posts', variables.postId] });
}
});
return (
<button
onClick={() => likeMutation.mutate({ postId: post.id, userId })}
disabled={likeMutation.isPending}
style={{
color: isLiked ? 'red' : 'gray',
opacity: likeMutation.isPending ? 0.6 : 1
}}
>
❤️ {post.likes}
</button>
);
}
⚠️ When NOT to Use Optimistic Updates
- Critical operations: Financial transactions, medical records
- Complex validation: When server might reject for many reasons
- Large payloads: When update logic is complex or data intensive
- Permission-based: When user might not have permission
In these cases, show loading state and wait for server confirmation.
🎯 Best Practices and Patterns
After mastering the basics of React Query, following these best practices will help you build maintainable, performant applications that leverage React Query's full power.
1. Organize API Functions
// api/users.ts
export interface User {
id: string;
name: string;
email: string;
}
export const usersApi = {
getAll: async (): Promise<User[]> => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch users');
return response.json();
},
getById: async (id: string): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
create: async (data: Omit<User, 'id'>): Promise<User> => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to create user');
return response.json();
},
update: async ({ id, data }: { id: string; data: Partial<User> }): Promise<User> => {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to update user');
return response.json();
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete user');
}
};
2. Create Custom Hooks for Queries
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi, User } from '../api/users';
// Query keys factory
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
// Get all users
export function useUsers() {
return useQuery({
queryKey: userKeys.lists(),
queryFn: usersApi.getAll,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// Get single user
export function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => usersApi.getById(id),
staleTime: 1000 * 60 * 5,
});
}
// Create user
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: usersApi.create,
onSuccess: (newUser) => {
// Optimistically add to list
queryClient.setQueryData<User[]>(userKeys.lists(), (old) => {
return old ? [...old, newUser] : [newUser];
});
// Invalidate to refetch from server
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// Update user
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: usersApi.update,
onSuccess: (updatedUser) => {
// Update specific user cache
queryClient.setQueryData(
userKeys.detail(updatedUser.id),
updatedUser
);
// Invalidate list
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// Delete user
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: usersApi.delete,
onSuccess: (_, deletedId) => {
// Remove from list cache
queryClient.setQueryData<User[]>(userKeys.lists(), (old) => {
return old?.filter(user => user.id !== deletedId);
});
// Remove detail cache
queryClient.removeQueries({ queryKey: userKeys.detail(deletedId) });
},
});
}
3. Configure Global Defaults
// App.tsx or main provider file
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Global defaults for ALL queries
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime)
refetchOnWindowFocus: false, // Don't refetch on tab focus
refetchOnReconnect: true, // Refetch on network reconnect
retry: 1, // Retry failed requests once
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
// Global defaults for ALL mutations
retry: 0, // Don't retry mutations
onError: (error) => {
console.error('Mutation error:', error);
// Show global error toast
},
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
4. Error Handling Patterns
// Create custom error class
class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: any
) {
super(message);
this.name = 'ApiError';
}
}
// Enhanced fetch with error handling
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.message || 'Request failed',
response.status,
errorData
);
}
return response.json();
}
// Use in component with proper error typing
function UserProfile({ userId }: { userId: string }) {
const { data, error, isError } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchApi<User>(`/api/users/${userId}`)
});
if (isError) {
if (error instanceof ApiError) {
if (error.status === 404) {
return <div>User not found</div>;
}
if (error.status === 403) {
return <div>Access denied</div>;
}
}
return <div>Error: {error.message}</div>;
}
return <div>{data?.name}</div>;
}
5. Prefetching Data
import { useQueryClient } from '@tanstack/react-query';
function UserList() {
const queryClient = useQueryClient();
const { data: users } = useUsers();
// Prefetch user details on hover
const prefetchUser = (userId: string) => {
queryClient.prefetchQuery({
queryKey: userKeys.detail(userId),
queryFn: () => usersApi.getById(userId),
staleTime: 1000 * 60 * 5,
});
};
return (
<ul>
{users?.map(user => (
<li
key={user.id}
onMouseEnter={() => prefetchUser(user.id)}
>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
// Prefetch on route loader
function UserDetailPage() {
const { userId } = useParams();
const queryClient = useQueryClient();
useEffect(() => {
// Prefetch related data
queryClient.prefetchQuery({
queryKey: ['posts', userId],
queryFn: () => getPostsByUser(userId!)
});
}, [userId, queryClient]);
const { data } = useUser(userId!);
return <div>{data?.name}</div>;
}
💡 React Query Best Practices Summary
- Use query keys factory for consistent, typed keys
- Create custom hooks to encapsulate query logic
- Organize API functions in separate files
- Set sensible global defaults in QueryClient
- Handle errors properly with typed error classes
- Use optimistic updates for better UX
- Prefetch on hover/navigation for snappy experience
- Invalidate precisely to avoid unnecessary refetches
- Use DevTools for debugging cache state
- Test with React Query using testing-library
6. Dependent Queries
// Query B depends on data from Query A
function UserPosts({ userId }: { userId: string }) {
// First query
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => getUser(userId)
});
// Second query depends on first
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => getPosts(user!.id),
enabled: !!user, // Only run when user exists
});
return <div>{posts?.length} posts</div>;
}
// Parallel dependent queries
function Dashboard() {
const { data: user } = useUser();
// These only run when user exists
const queries = useQueries({
queries: [
{
queryKey: ['posts', user?.id],
queryFn: () => getPosts(user!.id),
enabled: !!user,
},
{
queryKey: ['comments', user?.id],
queryFn: () => getComments(user!.id),
enabled: !!user,
},
{
queryKey: ['likes', user?.id],
queryFn: () => getLikes(user!.id),
enabled: !!user,
},
],
});
const [postsQuery, commentsQuery, likesQuery] = queries;
if (queries.some(q => q.isLoading)) {
return <div>Loading...</div>;
}
return (
<div>
<div>Posts: {postsQuery.data?.length}</div>
<div>Comments: {commentsQuery.data?.length}</div>
<div>Likes: {likesQuery.data?.length}</div>
</div>
);
}
🏋️ Hands-On Exercises
Exercise 1: Blog Post Manager
Goal: Build a blog post manager with React Query that supports viewing, creating, updating, and deleting posts.
Requirements:
- Fetch and display a list of blog posts
- Show loading and error states
- Create new posts with a form
- Edit existing posts inline
- Delete posts with confirmation
- Use proper TypeScript types
- Implement cache invalidation
- Add optimistic updates for like button
💡 Hint #1: Project Structure
// types/post.ts
export interface Post {
id: string;
title: string;
content: string;
author: string;
likes: number;
createdAt: string;
}
// api/posts.ts
export const postsApi = {
getAll: async (): Promise<Post[]> => { /* ... */ },
getById: async (id: string): Promise<Post> => { /* ... */ },
create: async (data: Omit<Post, 'id' | 'createdAt'>): Promise<Post> => { /* ... */ },
update: async (id: string, data: Partial<Post>): Promise<Post> => { /* ... */ },
delete: async (id: string): Promise<void> => { /* ... */ },
like: async (id: string): Promise<Post> => { /* ... */ }
};
💡 Hint #2: Custom Hooks
// hooks/usePosts.ts
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
};
export function usePosts() {
return useQuery({
queryKey: postKeys.lists(),
queryFn: postsApi.getAll
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
}
});
}
✅ Full Solution
// Full solution in next hint...
Try implementing this yourself first! The solution combines all concepts from this lesson.
Exercise 2: Shopping Cart with Optimistic Updates
Goal: Build a shopping cart that updates instantly when adding/removing items.
Requirements:
- Display product list from API
- Show current cart items
- Add items to cart with optimistic update
- Remove items from cart with optimistic update
- Update quantities with optimistic update
- Rollback on error
- Show total price (derived from cart)
- Persist cart to server
💡 Hint: Optimistic Cart Update
const addToCartMutation = useMutation({
mutationFn: (productId: string) => addToCart(productId),
onMutate: async (productId) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData<CartItem[]>(['cart']);
queryClient.setQueryData<CartItem[]>(['cart'], (old) => {
const existing = old?.find(item => item.productId === productId);
if (existing) {
return old?.map(item =>
item.productId === productId
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...(old || []), { productId, quantity: 1 }];
});
return { previousCart };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['cart'], context?.previousCart);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
}
});
Exercise 3: Infinite Scroll with React Query
Goal: Implement infinite scrolling for a feed using useInfiniteQuery.
Requirements:
- Fetch paginated posts
- Load more on scroll or button click
- Show loading state for next page
- Handle end of data
- Implement proper TypeScript types
💡 Hint: useInfiniteQuery Structure
import { useInfiniteQuery } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
content: string;
}
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
}
const fetchPosts = async ({ pageParam }: { pageParam: string | undefined }): Promise<PostsResponse> => {
const url = pageParam
? `/api/posts?cursor=${pageParam}`
: '/api/posts';
const response = await fetch(url);
return response.json();
};
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: fetchPosts,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
🧠 Knowledge Check Quiz
Question 1: Query vs Mutation
What is the main difference between useQuery and useMutation?
Show Answer
Answer: useQuery is for reading/fetching data (GET requests), while useMutation is for creating, updating, or deleting data (POST, PUT, PATCH, DELETE requests). Queries run automatically and cache results, while mutations only run when explicitly called with mutate().
Question 2: Cache Invalidation
When should you use invalidateQueries vs setQueryData?
Show Answer
Answer:
- invalidateQueries: Use when you want to mark data as stale and trigger a refetch. Best for most cases where you want fresh data from the server.
- setQueryData: Use when you already have the new data and want to update the cache immediately without a server request. Perfect for optimistic updates and when mutations return the updated data.
Question 3: Optimistic Updates
What are the key steps in implementing an optimistic update?
Show Answer
Answer: The key steps are:
- Cancel queries to prevent refetches from overwriting optimistic state
- Snapshot previous state using getQueryData
- Update cache optimistically with setQueryData
- Return context with the snapshot from onMutate
- Rollback on error by restoring previous state in onError
- Invalidate queries in onSettled to sync with server
Question 4: Query Keys
Why are query keys important, and what makes a good query key structure?
Show Answer
Answer: Query keys are crucial because they:
- Uniquely identify cached data
- Enable precise cache invalidation
- Support dependency tracking
A good query key structure:
- Uses arrays:
['users', '123'] - Orders from general to specific:
['posts', 'published', { page: 1 }] - Uses a key factory for consistency
- Includes all variables that affect the query
Question 5: staleTime vs gcTime
Explain the difference between staleTime and gcTime (formerly cacheTime).
Show Answer
Answer:
- staleTime: How long data is considered "fresh" (won't refetch). Default is 0 (immediately stale). Use this to control when refetches happen.
- gcTime: How long unused data stays in cache before garbage collection. Default is 5 minutes. Use this to control memory usage.
Example: With staleTime=5min and gcTime=10min, data stays fresh for 5 minutes, becomes stale but cached for another 5 minutes, then gets removed from cache.
Question 6: When to Use React Query
Should you use React Query for all state management? When is it appropriate vs when should you use useState or another solution?
Show Answer
Answer:
Use React Query for:
- Server state (data from APIs)
- Data that's cached and shared across components
- Data that can become stale
- Async data fetching
Use useState/useReducer for:
- UI state (modal open/closed, form input values)
- Client-only state that never touches the server
- Temporary state that doesn't need caching
- Component-local state
Use Zustand/Redux for:
- Global client state
- Complex state logic that's not server-related
- State that needs to persist across route changes
📝 Lesson Summary
What You've Learned
Congratulations! You've completed the React Query lesson. You now understand:
- ✅ The difference between server state and client state
- ✅ How to set up React Query with TypeScript
- ✅ Using
useQueryfor data fetching with automatic caching - ✅ Query keys and cache management strategies
- ✅ Using
useMutationfor create, update, delete operations - ✅ Cache invalidation patterns and when to use each
- ✅ Implementing optimistic updates for instant UI feedback
- ✅ React Query best practices and patterns
- ✅ Organizing API functions and custom hooks
- ✅ Error handling with typed errors
- ✅ Prefetching and dependent queries
- ✅ Global configuration and DevTools usage
🎯 Key Takeaways
- React Query is for server state: Use it for data from APIs, not UI state
- Queries auto-cache and refetch: No manual cache management needed
- Mutations update server state: Always invalidate affected queries
- Optimistic updates make apps feel instant: Update UI before server responds
- Query keys are crucial: Use a factory pattern for consistency
- Custom hooks encapsulate logic: One hook per resource type
- Configure sensible defaults: Set staleTime based on data volatility
- Use DevTools for debugging: Visualize cache state and queries
🚀 Next Steps
Now that you've mastered React Query, you can:
- Refactor existing data fetching code to use React Query
- Build applications with complex server state requirements
- Implement real-time features with polling or WebSocket integration
- Optimize performance with prefetching and parallel queries
- Proceed to Lesson 8.5: Architecture Best Practices
- Complete the Module 8 Project: Social Media Feed