If useQuery is for reading server state, then useMutation is for writing to it.
This guide covers everything you need to master useMutation from @tanstack/react-query.
📋 Table of Contents
- Installation & Setup
- The Big Picture
- Basic Usage
- Complete API Reference
- All Options Explained
- All Return Values Explained
- Optimistic Updates
- Advanced Patterns
- Error Handling
- useQuery vs useMutation
- Common Mistakes
- Production Best Practices
⚡ Quick Summary
TL;DR:
useMutationhandles:
- ✅ POST/PUT/PATCH/DELETE operations
- ✅ Optimistic UI updates with rollback
- ✅ Automatic retry with configurable logic
- ✅ Smart cache invalidation
- ✅ Error boundary integration
- ✅ Lifecycle hooks (onMutate, onSuccess, onError, onSettled)
Length: ~25 min read | Level: Beginner to Advanced
📦 Installation & Setup {#installation–setup}
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
🧠 The Big Picture {#the-big-picture}
Purpose:
- Server Writes – POST/PUT/PATCH/DELETE
- Optimistic Updates – Update UI before server confirms
- Retry Handling – Automatic retry with backoff
- Cache Invalidation – Keep cache synced
- Error Rollback – Revert on failure
Key Difference:
Unlike
useQuery, mutations DON’T run automatically.You trigger them manually via
mutate()ormutateAsync().
🚀 Basic Usage {#basic-usage}
import { useMutation } from '@tanstack/react-query'
function CreateUser() {
const mutation = useMutation({
mutationFn: (newUser) => {
return fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser)
}).then(res => res.json())
}
})
const handleSubmit = (e) => {
e.preventDefault()
mutation.mutate({
name: 'John Doe',
email: 'john@example.com'
})
}
if (mutation.isPending) return <div>Creating...</div>
if (mutation.isError) return <div>Error: {mutation.error.message}</div>
if (mutation.isSuccess) return <div>User created!</div>
return (
<form onSubmit={handleSubmit}>
<button type="submit">Create User</button>
</form>
)
}
📦 Complete API Reference {#complete-api-reference}
🎯 All Options
const mutation = useMutation({
mutationFn, // Required - API call function
mutationKey, // Optional - Unique identifier
onMutate, // Before mutation
onSuccess, // After success
onError, // After error
onSettled, // After completion (success/error)
retry, // Retry logic
retryDelay, // Delay between retries
networkMode, // Network behavior
gcTime, // Garbage collection time
meta, // Custom metadata
throwOnError, // Error Boundary integration
scope, // Mutation scope
}, queryClient)
🎁 All Return Values
const {
data, // Mutation result
error, // Error object
isError, // Error state
isIdle, // Not triggered
isPending, // In progress
isPaused, // Network paused
isSuccess, // Succeeded
failureCount, // Failed attempts
failureReason, // Last error
mutate, // Sync trigger
mutateAsync, // Async trigger
reset, // Reset state
status, // 'idle' | 'pending' | 'error' | 'success'
submittedAt, // Timestamp
variables, // Input variables
context, // onMutate return value
} = useMutation(...)
🔄 All Options Explained {#all-options-explained}
1. mutationFn (Required) 🔑
The function that performs the mutation.
// POST
mutationFn: (newUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
}).then(r => r.json())
// PUT
mutationFn: ({ id, ...data }) =>
fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
}).then(r => r.json())
// DELETE
mutationFn: (userId) =>
fetch(`/api/users/${userId}`, { method: 'DELETE' })
// With Axios
mutationFn: (data) => axios.post('/api/users', data)
Type:
mutationFn: (variables: TVariables) => Promise<TData>
Rules:
- Must return a Promise
- Should throw on error
- Can accept any variables
2. mutationKey 🏷
Optional unique identifier.
mutationKey: ['createUser']
mutationKey: ['updateUser', userId]
Use cases:
- DevTools grouping
- Global state access
- Mutation deduplication
3. onMutate 🚀
Runs BEFORE the mutation.
onMutate: async (newUser) => {
// 1. Cancel ongoing queries
await queryClient.cancelQueries({ queryKey: ['users'] })
// 2. Snapshot for rollback
const previousUsers = queryClient.getQueryData(['users'])
// 3. Optimistically update
queryClient.setQueryData(['users'], old => [...old, newUser])
// 4. Return context
return { previousUsers }
}
🔥 Most powerful feature for optimistic updates!
4. onSuccess ✅
Runs after successful mutation.
onSuccess: (data, variables, context) => {
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ['users'] })
// Show toast
toast.success('User created!')
// Navigate
navigate('/users')
}
Parameters:
-
data– Response from mutationFn -
variables– Input passed to mutate() -
context– Value from onMutate
5. onError ❌
Runs when mutation fails.
onError: (error, variables, context) => {
// Rollback optimistic update
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers)
}
// Show error
toast.error(`Error: ${error.message}`)
// Log error
Sentry.captureException(error)
}
6. onSettled 🏁
Runs ALWAYS (success or error).
onSettled: (data, error, variables, context) => {
// Refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['users'] })
// Cleanup
setIsSubmitting(false)
}
Best practice: Put invalidation here instead of onSuccess.
7. retry 🔁
Configure retry behavior.
// No retry (default)
retry: false
// Retry 3 times
retry: 3
// Custom logic
retry: (failureCount, error) => {
// Don't retry 4xx errors
if (error.status >= 400 && error.status < 500) return false
return failureCount < 3
}
⚠️ Default is false (unlike useQuery which defaults to 3)
8. retryDelay ⏱
Delay between retries.
// Fixed (1 second)
retryDelay: 1000
// Exponential backoff
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000)
9. networkMode 🌐
networkMode: 'online' // Only when online (default)
networkMode: 'always' // Even offline
networkMode: 'offlineFirst' // Cache first
10. gcTime 🗑
How long unused mutation stays in cache.
gcTime: 5 * 60 * 1000 // 5 minutes (default)
11. meta 📝
Custom metadata.
meta: {
errorMessage: 'Failed to create user',
trackingId: 'user-creation'
}
12. throwOnError 🚨
Throw errors to Error Boundaries.
throwOnError: true
// Or conditional
throwOnError: (error) => error.status >= 500
🎁 All Return Values Explained {#all-return-values-explained}
Core Trigger Methods
mutate – Synchronous
// Fire and forget
mutation.mutate({ name: 'John' })
// With callbacks
mutation.mutate(
{ name: 'John' },
{
onSuccess: (data) => console.log(data),
onError: (error) => console.log(error)
}
)
Does NOT return a promise.
mutateAsync – Asynchronous
const handleSubmit = async () => {
try {
const data = await mutation.mutateAsync({ name: 'John' })
navigate('/success')
} catch (error) {
console.log('Error:', error)
}
}
Returns a promise.
When to use:
- ✅ Form submissions
- ✅ Async/await flow
- ✅ Sequential mutations
State Properties
data
const { data } = useMutation(...)
// Response from successful mutation
error
const { error } = useMutation(...)
// Error object if failed
status
const { status } = useMutation(...)
// 'idle' | 'pending' | 'error' | 'success'
Status Booleans
isIdle
// Mutation not triggered yet
const { isIdle } = useMutation(...)
isPending
// Mutation in progress
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
isSuccess
{mutation.isSuccess && <SuccessMessage />}
isError
{mutation.isError && <ErrorMessage error={mutation.error} />}
Failure Info
failureCount
// Number of failed attempts
const { failureCount } = useMutation(...)
failureReason
// Last error that caused failure
const { failureReason } = useMutation(...)
Other Properties
variables
// Input passed to last mutation
const { variables } = useMutation(...)
console.log('Creating:', variables)
submittedAt
// Timestamp of last submission
const { submittedAt } = useMutation(...)
context
// Value returned from onMutate
const { context } = useMutation(...)
reset()
// Reset to idle state
mutation.reset()
Use cases:
- Clear success/error
- Reset form
- Allow re-submission
🔥 Optimistic Updates – Full Lifecycle {#optimistic-updates-full-lifecycle}
What are Optimistic Updates?
Update UI immediately before server confirms. If it fails, roll back.
Complete Flow:
User clicks → onMutate → mutationFn → onSuccess/onError → onSettled
Complete Example:
function TodoList() {
const queryClient = useQueryClient()
const addTodo = useMutation({
mutationFn: (newTodo) => axios.post('/api/todos', newTodo),
// 1. Before mutation
onMutate: async (newTodo) => {
// Cancel to prevent race conditions
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot for rollback
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update
queryClient.setQueryData(['todos'], old => [
...old,
{ ...newTodo, id: Date.now(), optimistic: true }
])
return { previousTodos }
},
// 2. On success
onSuccess: (data) => {
queryClient.setQueryData(['todos'], old =>
old.map(todo => todo.optimistic ? data : todo)
)
toast.success('Todo added!')
},
// 3. On error - Rollback
onError: (error, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
toast.error('Failed to add todo')
},
// 4. Always
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
return (
<button onClick={() => addTodo.mutate({ title: 'New Todo' })}>
Add Todo
</button>
)
}
When to Use:
✅ Good for:
- Adding items to lists
- Toggling booleans (like/unlike)
- Incrementing counters
❌ Avoid for:
- Complex calculations
- File uploads
- Payment processing
🎯 Advanced Patterns {#advanced-patterns}
1. Sequential Mutations
const createUser = useMutation({ mutationFn: createUserAPI })
const sendEmail = useMutation({ mutationFn: sendEmailAPI })
const handleSignup = async (data) => {
try {
const user = await createUser.mutateAsync(data)
await sendEmail.mutateAsync({ email: user.email })
toast.success('Signup complete!')
} catch (error) {
toast.error('Signup failed')
}
}
2. Parallel Mutations
const [profile, avatar] = await Promise.all([
updateProfile.mutateAsync(profileData),
uploadAvatar.mutateAsync(avatarFile)
])
3. Global Config
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: 1,
onError: (error) => console.error(error)
}
}
})
4. Undo/Redo
const deleteTodo = useMutation({
mutationFn: deleteTodoAPI,
onMutate: async (todoId) => {
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], old =>
old.filter(t => t.id !== todoId)
)
return { previous, todoId }
},
onSuccess: (data, todoId, context) => {
toast.success('Deleted', {
action: {
label: 'Undo',
onClick: () => {
queryClient.setQueryData(['todos'], context.previous)
}
}
})
}
})
🚨 Error Handling {#error-handling}
Component-Level
const mutation = useMutation({
mutationFn: createUser,
onError: (error) => {
if (error.response?.status === 400) {
toast.error('Invalid input')
} else if (error.response?.status === 401) {
navigate('/login')
} else {
toast.error('Something went wrong')
}
}
})
Global Handling
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
Sentry.captureException(error)
if (!error.handled) {
toast.error('An error occurred')
}
}
}
}
})
Error Boundaries
const mutation = useMutation({
mutationFn: createUser,
throwOnError: true // Throws to Error Boundary
})
🆚 useQuery vs useMutation {#usequery-vs-usemutation}
| Feature | useQuery | useMutation |
|---|---|---|
| Auto run | ✅ Yes | ❌ No |
| Caching | ✅ Yes | ❌ No |
| Optimistic updates | ❌ No | ✅ Yes |
| Trigger | Automatic | Manual |
| Use case | GET | POST/PUT/DELETE |
| Default retry | 3 | 0 |
⚠️ Common Mistakes {#common-mistakes}
❌ 1. Forgetting Rollback
// BAD
onMutate: (data) => {
queryClient.setQueryData(['users'], data)
// Missing: return context
}
// GOOD
onMutate: async (data) => {
const previous = queryClient.getQueryData(['users'])
queryClient.setQueryData(['users'], data)
return { previous } // ✅
},
onError: (err, data, context) => {
queryClient.setQueryData(['users'], context.previous) // ✅
}
❌ 2. Retrying POST Blindly
// BAD - Can create duplicates
retry: 3
// GOOD
retry: false // Or use smart retry logic
❌ 3. Not Invalidating
// BAD
onSuccess: () => {
toast.success('Created!')
// Missing: invalidation
}
// GOOD
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }) // ✅
toast.success('Created!')
}
❌ 4. Overusing Optimistic Updates
// BAD - Complex calculation should wait
onMutate: (input) => {
const result = complexCalculation(input) // ⚠️
queryClient.setQueryData(['data'], result)
}
// GOOD - Wait for server
onSuccess: (result) => {
queryClient.setQueryData(['data'], result) // ✅
}
❌ 5. Not Handling Loading
// BAD
<button onClick={() => mutation.mutate(data)}>Submit</button>
// GOOD
<button
onClick={() => mutation.mutate(data)}
disabled={mutation.isPending} // ✅
>
{mutation.isPending ? 'Submitting...' : 'Submit'}
</button>
❌ 6. Using Wrong Trigger
// BAD
const handleSubmit = async () => {
mutation.mutate(data)
navigate('/success') // ⚠️ Navigates before completion
}
// GOOD
const handleSubmit = async () => {
await mutation.mutateAsync(data) // ✅
navigate('/success')
}
🏗 Production Best Practices {#production-best-practices}
✅ 1. Use mutateAsync for Forms
const handleSubmit = async (data) => {
try {
await mutation.mutateAsync(data)
resetForm()
navigate('/success')
} catch (error) {
// Already handled by onError
}
}
✅ 2. Always Handle Rollback
onMutate: async (data) => {
await queryClient.cancelQueries({ queryKey: ['data'] })
const previous = queryClient.getQueryData(['data'])
queryClient.setQueryData(['data'], data)
return { previous } // ✅
},
onError: (err, vars, context) => {
queryClient.setQueryData(['data'], context.previous) // ✅
}
✅ 3. Avoid Retry for Non-Idempotent APIs
// PUT is idempotent
retry: 2 // ✅
// POST is not
retry: false // ✅
✅ 4. Keep mutationFn Pure
// BAD
mutationFn: async (data) => {
const result = await api.post(data)
toast.success('Done!') // ⚠️ Side effect
return result
}
// GOOD
mutationFn: (data) => api.post(data), // ✅
onSuccess: () => toast.success('Done!') // ✅
✅ 5. Invalidate in onSettled
onSettled: () => {
// ✅ Runs on both success and error
queryClient.invalidateQueries({ queryKey: ['users'] })
}
✅ 6. TypeScript Support
interface User { id: number; name: string }
interface CreateUserInput { name: string }
const createUser = useMutation<User, Error, CreateUserInput>({
mutationFn: (input) => api.createUser(input),
onSuccess: (data) => {
// data is typed as User ✅
}
})
🔥 Key Takeaways
useMutation is a:
- ✅ Transaction Manager – Coordinates writes
- ✅ Optimistic UI Engine – Updates before confirmation
- ✅ Retry Controller – Handles failures
- ✅ Rollback Mechanism – Reverts on errors
- ✅ Server Sync Bridge – Keeps cache in sync
📌 Final Thought
Understanding useMutation is mandatory for scalable React apps.
The combination of:
- Optimistic updates
- Smart retry
- Cache invalidation
- Error rollback
Makes it the most powerful tool for server writes.
🚀 Next Steps
- Read TanStack Query docs
- Master
useQueryfor reads - Learn query invalidation strategies
- Practice optimistic update patterns
- Setup React Query DevTools
💡 Pro Tips
Master these first:
- Basic mutation with
mutate() - Async flow with
mutateAsync() - Cache invalidation in
onSuccess - Error handling in
onError
Then move to optimistic updates!
🤝 Let’s Connect
Found this helpful?
- ❤️ Save for reference
- 🔄 Share with your team
- 💬 Ask questions in comments
- 🐦 Follow for more React content
What’s your biggest mutation challenge? Comment below! 👇
📚 Official Docs:
https://tanstack.com/query/latest/docs/react/overview
Happy mutating! 🎯
Tags: #react #tanstack #reactquery #javascript #webdev #frontend #mutations
