
Are you struggling with form submissions in React Query? Finding yourself confused about how to handle API responses, or scratching your head over when to use useMutation
versus useQuery
? You're not alone. Many developers face these exact challenges when working with React Query's mutation system.
This comprehensive guide will walk you through everything you need to know about useMutation
, from basic form submissions to advanced patterns and best practices. By the end, you'll have a solid understanding of how to effectively manage your application's data mutations.
What is useMutation?
At its core, useMutation
is a React Query hook designed for handling server-side effects - primarily creating, updating, or deleting data. Unlike useQuery
which is meant for data fetching, useMutation
is your go-to tool when you need to make changes to your server state.
Here's a simple example to illustrate:
import { useMutation } from '@tanstack/react-query';
function CreateTodoForm() {
const mutation = useMutation({
mutationFn: (newTodo) => {
return axios.post('/api/todos', newTodo)
},
})
return (
<form onSubmit={(e) => {
e.preventDefault()
mutation.mutate({ title: 'Do laundry' })
}}>
<button type="submit">Create Todo</button>
</form>
)
}
Why Use useMutation?
You might be wondering why you should use useMutation
instead of a simple API call with fetch
or axios
. Here are the key benefits:
Automatic Loading States: No more manual tracking of loading states with
useState
Built-in Error Handling: Comprehensive error state management out of the box
Retry Logic: Automatic retries for failed mutations
Optimistic Updates: Update your UI instantly while waiting for the server response
Cache Integration: Seamless integration with React Query's powerful caching system
Understanding Mutation States
One of the most powerful aspects of useMutation
is its built-in state management. Every mutation goes through different states that you can track:
const mutation = useMutation({
mutationFn: createTodo,
})
console.log(mutation.isIdle) // True if the mutation hasn't been called yet
console.log(mutation.isLoading) // True while the mutation is in progress
console.log(mutation.isError) // True if the mutation encountered an error
console.log(mutation.isSuccess) // True if the mutation was successful
Basic Usage: Form Submissions
Let's tackle one of the most common use cases: form submissions. Many developers struggle with handling form data and responses effectively. Here's a complete example:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
interface TodoFormData {
title: string;
description: string;
}
function TodoForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (formData: TodoFormData) => {
return axios.post('/api/todos', formData);
},
onSuccess: (data) => {
// Invalidate and refetch todos list
queryClient.invalidateQueries({ queryKey: ['todos'] });
console.log('New todo created:', data);
},
onError: (error) => {
console.error('Failed to create todo:', error);
}
});
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
mutation.mutate({
title: formData.get('title') as string,
description: formData.get('description') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title:</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label htmlFor="description">Description:</label>
<textarea id="description" name="description" required />
</div>
<button
type="submit"
disabled={mutation.isLoading}
>
{mutation.isLoading ? 'Creating...' : 'Create Todo'}
</button>
{mutation.isError && (
<div style={{ color: 'red' }}>
Error: {mutation.error.message}
</div>
)}
{mutation.isSuccess && (
<div style={{ color: 'green' }}>
Todo created successfully!
</div>
)}
</form>
);
}
This example demonstrates several key concepts:
Type Safety: Using TypeScript interfaces for form data
Form Handling: Proper form submission with FormData API
Loading States: Disabling the submit button during submission
Error Handling: Displaying error messages when something goes wrong
Success Feedback: Showing success messages to users
Cache Management: Invalidating related queries after successful mutation
Error Handling Deep Dive
Error handling is crucial for providing a good user experience. React Query's useMutation
provides several ways to handle errors:
// Global error handler
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error, variables, context, mutation) => {
// Global error handling
console.error(`Something went wrong: ${error.message}`);
},
}),
});
// Per-mutation error handling
const mutation = useMutation({
mutationFn: createTodo,
onError: (error, variables, context) => {
// Handle specific mutation errors
if (error.response?.status === 400) {
console.error('Validation error:', error.response.data);
} else if (error.response?.status === 401) {
// Handle unauthorized access
navigate('/login');
}
},
});
// Try-catch block for immediate handling
try {
await mutation.mutateAsync(newTodo);
} catch (error) {
// Handle error immediately
console.error('Failed to create todo:', error);
}
Advanced Patterns
1. Optimistic Updates
Optimistic updates improve perceived performance by updating the UI before the server confirms the change:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update to the new value
queryClient.setQueryData(['todos'], (old) => {
return old.map((todo) =>
todo.id === newTodo.id ? newTodo : todo
);
});
// Return context with the previous value
return { previousTodos };
},
onError: (err, newTodo, context) => {
// If the mutation fails, use the context we returned above
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
2. Mutation Queue
React Query automatically queues mutations, ensuring they execute in order:
// These will execute in order
mutation.mutate(todo1);
mutation.mutate(todo2);
mutation.mutate(todo3);
3. Side Effects and Callbacks
React Query provides several callback opportunities for handling side effects:
const mutation = useMutation({
mutationFn: createTodo,
onMutate: (variables) => {
// Called before the mutation function
console.log('About to create:', variables);
},
onSuccess: (data, variables) => {
// Called on successful mutation
toast.success('Todo created successfully!');
},
onError: (error, variables, context) => {
// Called on mutation error
toast.error('Failed to create todo');
},
onSettled: (data, error, variables, context) => {
// Called after mutation is either successful or has failed
console.log('Mutation settled');
},
});
Best Practices and Common Pitfalls
1. Handling Response Data
One common confusion is how to access mutation response data. Here's the right way:
// ❌ Don't do this
const [responseData, setResponseData] = useState(null);
mutation.mutate(data, {
onSuccess: (response) => setResponseData(response),
});
// ✅ Do this instead
const { data, isSuccess } = mutation;
// Access data directly from mutation object
2. Proper Type Definitions
Using TypeScript? Here's how to properly type your mutations:
interface Todo {
id: number;
title: string;
completed: boolean;
}
interface CreateTodoDTO {
title: string;
}
const mutation = useMutation<Todo, Error, CreateTodoDTO>({
mutationFn: (newTodo) => {
return axios.post<Todo>('/api/todos', newTodo);
},
});
3. Reset Mutation State
Remember to reset mutation state when needed:
function TodoForm() {
const mutation = useMutation({
mutationFn: createTodo,
});
// Reset mutation state when component unmounts or when needed
useEffect(() => {
return () => {
mutation.reset();
};
}, []);
return (
// ... form JSX
);
}
Advanced Use Cases
1. Batch Mutations
Need to perform multiple mutations at once? Here's how:
function BatchTodoCreator() {
const mutation = useMutation({
mutationFn: async (todos) => {
const promises = todos.map(todo =>
axios.post('/api/todos', todo)
);
return Promise.all(promises);
},
});
const createMultipleTodos = () => {
const todos = [
{ title: 'Todo 1' },
{ title: 'Todo 2' },
{ title: 'Todo 3' },
];
mutation.mutate(todos);
};
return (
<button
onClick={createMultipleTodos}
disabled={mutation.isLoading}
>
Create Multiple Todos
</button>
);
}
2. Dependent Mutations
Sometimes you need to chain mutations based on previous results:
function CreateProjectWithTasks() {
const createProject = useMutation({
mutationFn: (project) => axios.post('/api/projects', project),
});
const createTask = useMutation({
mutationFn: (task) => axios.post('/api/tasks', task),
});
const handleCreate = async () => {
try {
// Create project first
const project = await createProject.mutateAsync({
name: 'New Project',
});
// Then create associated tasks
await createTask.mutateAsync({
projectId: project.id,
title: 'First Task',
});
} catch (error) {
console.error('Failed to create project with tasks:', error);
}
};
return (
<button
onClick={handleCreate}
disabled={createProject.isLoading || createTask.isLoading}
>
Create Project with Tasks
</button>
);
}
3. Retry Configuration
Customize retry behavior for failed mutations:
const mutation = useMutation({
mutationFn: createTodo,
retry: 3, // Number of retry attempts
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
Integration with React Query Cache
1. Updating Query Cache After Mutation
One of the most powerful features of React Query is its cache management. Here's how to update the cache after a mutation:
function TodoList() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: (newTodo) => {
// Update the todos list in cache
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
// Or invalidate the query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<div>
{/* Todo list rendering */}
</div>
);
}
2. Optimistic Updates with Rollback
Here's a more complex example of optimistic updates with proper error handling:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
// Snapshot the previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);
// Optimistically update to the new value
queryClient.setQueryData(['todos', newTodo.id], newTodo);
// Return a context object with the snapshotted value
return { previousTodo };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todos', newTodo.id],
context.previousTodo
);
},
onSettled: (newTodo) => {
queryClient.invalidateQueries({
queryKey: ['todos', newTodo.id],
});
},
});
Performance Optimization
1. Debouncing Mutations
When dealing with rapid mutations, implement debouncing:
import { useMutation } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
function SearchForm() {
const mutation = useMutation({
mutationFn: searchAPI,
});
const debouncedMutate = React.useMemo(
() => debounce(mutation.mutate, 500),
[mutation.mutate]
);
return (
<input
type="text"
onChange={(e) => debouncedMutate(e.target.value)}
/>
);
}
2. Preventing Double Submissions
Protect against accidental double submissions:
function SubmitForm() {
const mutation = useMutation({
mutationFn: submitData,
});
const handleSubmit = async (e) => {
e.preventDefault();
if (mutation.isLoading) return; // Prevent double submission
const formData = new FormData(e.target);
mutation.mutate(Object.fromEntries(formData));
};
return (
<form onSubmit={handleSubmit}>
<button
type="submit"
disabled={mutation.isLoading}
>
{mutation.isLoading ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Common Gotchas and Solutions
1. Handling Stale Closures
Be careful with stale closures in mutation callbacks:
// ❌ Don't do this
const [count, setCount] = useState(0);
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// This will use a stale count value
setCount(count + 1);
},
});
// ✅ Do this instead
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
setCount(prev => prev + 1);
},
});
2. Proper Error Boundaries
Implement error boundaries for mutation errors:
class MutationErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong with the mutation.</h1>;
}
return this.props.children;
}
}
Testing Mutations
Testing your mutations is crucial for maintaining a reliable application. Here's how to test different scenarios:
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
describe('Todo Mutation', () => {
let queryClient;
beforeEach(() => {
queryClient = new QueryClient();
});
it('should create a todo successfully', async () => {
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const { result, waitFor } = renderHook(
() => useMutation({ mutationFn: createTodo }),
{ wrapper }
);
result.current.mutate({ title: 'Test Todo' });
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toBeDefined();
});
it('should handle errors appropriately', async () => {
// Test implementation
});
});
Conclusion
React Query's useMutation
hook is a powerful tool for managing server state mutations in your React applications. By following the patterns and best practices outlined in this guide, you can:
Handle form submissions effectively
Manage loading and error states
Implement optimistic updates
Maintain cache consistency
Write testable mutation logic
Remember these key takeaways:
Always handle loading and error states appropriately
Use TypeScript for better type safety
Implement proper error boundaries
Take advantage of React Query's built-in cache management
Test your mutations thoroughly
Additional Resources
By following these guidelines and best practices, you'll be well-equipped to handle any data mutation scenarios in your React applications using React Query's useMutation
hook.