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.
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.
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>
);
}
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 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>
onSuccessmutation.reset()// 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
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.
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'
});
}
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' });
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);
});
}
});
| 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) |
// 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');
};
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 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.
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.
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>
);
}
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'] });
}
});
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'] });
}
});
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>
);
}
In these cases, show loading state and wait for server confirmation.
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.
// 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');
}
};
// 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) });
},
});
}
// 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>
);
}
// 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>;
}
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>;
}
// 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>
);
}
Goal: Build a blog post manager with React Query that supports viewing, creating, updating, and deleting posts.
Requirements:
// 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> => { /* ... */ }
};
// 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 in next hint...
Try implementing this yourself first! The solution combines all concepts from this lesson.
Goal: Build a shopping cart that updates instantly when adding/removing items.
Requirements:
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'] });
}
});
Goal: Implement infinite scrolling for a feed using useInfiniteQuery.
Requirements:
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>
);
}
What is the main difference between useQuery and useMutation?
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().
When should you use invalidateQueries vs setQueryData?
Answer:
What are the key steps in implementing an optimistic update?
Answer: The key steps are:
Why are query keys important, and what makes a good query key structure?
Answer: Query keys are crucial because they:
A good query key structure:
['users', '123']['posts', 'published', { page: 1 }]Explain the difference between staleTime and gcTime (formerly cacheTime).
Answer:
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.
Should you use React Query for all state management? When is it appropriate vs when should you use useState or another solution?
Answer:
Use React Query for:
Use useState/useReducer for:
Use Zustand/Redux for:
Congratulations! You've completed the React Query lesson. You now understand:
useQuery for data fetching with automatic cachinguseMutation for create, update, delete operationsNow that you've mastered React Query, you can: