TanStack Query 07 ⚛️ Basic Mutations — Modifying data with useMutation
Queries read data. Mutations write it. The useMutation hook is the counterpart to useQuery for operations that create, update, or delete server state. It does not cache data in the same way a query does, and it does not run automatically on mount. A mutation is triggered explicitly by calling the mutate function, usually from an event handler. It tracks the request’s lifecycle—idle, pending, success, or error—and exposes callbacks that fire at each stage, making it the mechanism for keeping the UI in sync after a write .
The previous chapter covered window focus behavior for queries. This chapter covers the write path: the mutation function, the lifecycle callbacks, the difference between mutate and mutateAsync, and the critical pattern of invalidating related queries after a successful mutation so that the UI reflects the new server state.
Key point: A mutation does not automatically update the cache. After a successful write, the related queries are still holding stale data unless the mutation explicitly invalidates them. The onSuccess callback is where queryClient.invalidateQueries is called, which marks the related queries as stale and triggers a background refetch. Without this step, the UI continues to display the old data even though the server has changed.
Why mutations require explicit invalidation
The cache-sync problem.
Queries cache their results. When a mutation changes the server state, the cached query data becomes outdated, but TanStack Query has no way to know that a specific mutation affects a specific query. The connection between the write and the read is application-specific: posting a new todo affects the ['todos'] query, but the library cannot infer that relationship. The mutation must declare it by calling invalidateQueries with the affected query key . This marks the query as stale and triggers a refetch if the query is currently observed by a mounted component.
The mutate-versus-mutateAsync problem.
mutate is fire-and-forget. It triggers the mutation and returns undefined. The result is available through the hook’s state (data, error, isPending) and through the callbacks. mutateAsync returns a promise that resolves with the mutation’s data or rejects with the error. The promise form is useful when the caller needs to await the result—for example, to navigate away after a successful submit, or to chain multiple mutations sequentially. The callbacks passed to mutate fire only for the latest call, while mutateAsync gives a separate promise per call, which is safer for concurrent mutations .
The optimistic-update problem.
An optimistic update applies the expected result to the cache before the mutation completes, then rolls back if the mutation fails. The onMutate callback captures the previous state, applies the optimistic change, and returns the captured state. The onError callback uses that returned value to restore the previous state. The onSettled callback invalidates the query to ensure the final state matches the server, regardless of whether the mutation succeeded or failed . This pattern is covered in depth in a later chapter; the basic mutation chapter establishes the callbacks that make it possible.
a. The mutation function and the mutate call
A mutation is defined with useMutation, which accepts a mutationFn and optional lifecycle callbacks. The mutationFn receives the variables passed to mutate and returns a promise.
import { useMutation } from '@tanstack/react-query'
interface Todo {
id: number
title: string
}
// The mutation function receives the variables and returns a promise
async function addTodo(newTodo: Omit<Todo, 'id'>): Promise<Todo> {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to add todo')
return response.json()
}
function AddTodoForm() {
const mutation = useMutation({
mutationFn: addTodo,
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// mutate triggers the mutation with the variables
mutation.mutate({ title: 'New todo' })
}
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
)
}
The mutationFn must throw on HTTP errors, just like a query function. A fetch response with status 500 resolves successfully unless the function checks response.ok and throws explicitly. The mutation.isPending flag is true while the request is running, which is used to disable the submit button and show a loading indicator. The mutation.isError flag is true when the function threw, and mutation.error contains the thrown error .
b. The lifecycle callbacks and cache invalidation
The useMutation options accept four callbacks: onMutate, onSuccess, onError, and onSettled. The most important for basic mutations is onSuccess, where the related queries are invalidated.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Invalidate the todos query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error) => {
// The mutation failed; the cache is unchanged
console.error('Failed to add todo:', error.message)
},
onSettled: () => {
// Runs after success or error; useful for cleanup
},
})
}
The onSuccess callback fires when the mutation function resolves. Calling queryClient.invalidateQueries({ queryKey: ['todos'] }) marks every query whose key starts with 'todos' as stale. If a component is currently observing that query, a background refetch runs, and the new todo appears in the list . The onError callback fires when the function throws. The onSettled callback fires after both success and error, and is useful for cleanup that should happen regardless of the outcome.
The invalidation can be awaited to ensure the refetch completes before the mutation is considered fully settled. If onSuccess returns a promise, TanStack Query awaits it, and isPending remains true until the refetch finishes .
c. mutateAsync and per-call callbacks
The mutate function accepts a second argument: an object of per-call callbacks. These fire only for that specific call, and only while the component is still mounted. The mutateAsync function returns a promise, which is useful when the result needs to be awaited.
function TodoForm() {
const mutation = useMutation({ mutationFn: addTodo })
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
// mutateAsync returns a promise that resolves with the data
const newTodo = await mutation.mutateAsync({ title: 'New todo' })
console.log('Created:', newTodo)
// Navigate or perform other side effects after success
} catch (error) {
console.error('Failed:', error)
}
}
return <form onSubmit={handleSubmit}>...</form>
}
The per-call callbacks on mutate are an alternative to the hook-level callbacks when the side effect is specific to one call site. For example, a call site might want to navigate away after success, while another call site in the same component might want to show a toast. The per-call onSuccess fires only for that call, and only if the component is still mounted when the mutation settles . The mutateAsync form gives the caller a promise per call, which is safer for concurrent mutations because each promise resolves or rejects independently.
Complete Example Session
// ============================================
// PART 1: THE MUTATION FUNCTION
// ============================================
interface Todo {
id: number
title: string
completed: boolean
}
async function addTodo(title: string): Promise<Todo> {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false }),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
// ============================================
// PART 2: THE HOOK WITH INVALIDATION
// ============================================
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Mark the todos query as stale and refetch if observed
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
// ============================================
// PART 3: THE COMPONENT USING THE MUTATION
// ============================================
function AddTodoForm() {
const [title, setTitle] = useState('')
const mutation = useAddTodo()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
// mutate is fire-and-forget
mutation.mutate(title, {
onSuccess: () => setTitle(''), // Clear input on success
onError: (error) => alert(error.message),
})
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={mutation.isPending}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
)
}
// ============================================
// PART 4: USING mutateAsync FOR NAVIGATION
// ============================================
function CreateTodoPage() {
const navigate = useNavigate()
const mutation = useAddTodo()
const handleCreate = async (title: string) => {
try {
const newTodo = await mutation.mutateAsync(title)
// Navigate to the detail page after creation
navigate(`/todos/${newTodo.id}`)
} catch (error) {
// The error is already in mutation.error
console.error('Create failed:', error)
}
}
return <button onClick={() => handleCreate('New todo')}>Create</button>
}
// ============================================
// PART 5: THE QUERY THAT CONSUMES THE RESULT
// ============================================
function TodoList() {
// This query is invalidated by the mutation
const { data, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <p>Loading...</p>
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
The five parts demonstrate the complete mutation flow: the function that performs the write, the hook that wraps it with invalidation, the component that triggers it with mutate, the variant that uses mutateAsync for navigation, and the query that receives the refetched data after invalidation. The critical connection is Part 2: the onSuccess callback invalidates the ['todos'] query, which causes Part 5’s component to re-render with the new todo.
Quick Reference
useMutation Options
| Option | Type | Purpose |
|---|---|---|
mutationFn | (vars) => Promise<TData> | The function that performs the write |
onMutate | (vars) => context | Before the request; for optimistic updates |
onSuccess | (data, vars, ctx) => void | After success; for invalidation |
onError | (error, vars, ctx) => void | After failure; for rollback |
onSettled | (data, error, vars) => void | After success or failure; for cleanup |
Mutation Result
| Property | Type | Meaning |
|---|---|---|
mutate | (vars, callbacks?) => void | Fire-and-forget trigger |
mutateAsync | (vars, callbacks?) => Promise<TData> | Promise-returning trigger |
data | TData | undefined | Last successful result |
error | TError | null | Last error |
isPending | boolean | Request is running |
isSuccess | boolean | Last request succeeded |
isError | boolean | Last request failed |
Invalidation Pattern
| Step | Code |
|---|---|
| Get client | const queryClient = useQueryClient() |
| On success | onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) |
| Effect | Related queries refetch in background |
Best Practices
✅ Do This:
// Check response.ok in the mutationFn
if (!response.ok) throw new Error(`HTTP ${response.status}`) // ✅
// Invalidate related queries on success
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) // ✅
// Disable the submit button while pending
<button disabled={mutation.isPending}>Add</button> // ✅
// Use mutateAsync when you need to await the result
const result = await mutation.mutateAsync(vars) // ✅
// Pass per-call callbacks for call-site-specific side effects
mutation.mutate(vars, { onSuccess: () => navigate('/') }) // ✅
// Return a promise from onSuccess to await the refetch
onSuccess: async () => { await queryClient.invalidateQueries(...) } // ✅
❌ Don’t Do This:
// Forget to invalidate after a write
useMutation({ mutationFn: addTodo }) // UI shows stale data // ❌
// Assume mutate returns a promise
const result = mutation.mutate(vars) // Returns undefined // ❌
// Access mutation.data before it exists
console.log(mutation.data.title) // Crashes when undefined // ❌
// Use per-call callbacks for critical cleanup
// They do not fire if the component unmounts before settling // ❌
// Ignore the error state
mutation.mutate(vars) // No error handling // ❌
// Use mutate for sequential dependent mutations
// Use mutateAsync and await each one // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI shows old data after mutation | No invalidation in onSuccess | Call invalidateQueries |
mutate returns undefined | It is fire-and-forget | Use mutateAsync for the promise |
| Error state never set | fetch does not throw on 4xx/5xx | Check response.ok and throw |
| Per-call callback not fired | Component unmounted before settling | Use mutateAsync or hook-level callback |
| Invalidation does not refetch | Query is not observed by a mounted component | Expected; it refetches on next mount |
| Pending state flickers | onSuccess returns before refetch completes | Return a promise from onSuccess |
Real-World Examples
1. Basic mutation
const mutation = useMutation({ mutationFn: addTodo })
mutation.mutate('New todo')
2. Invalidate on success
useMutation({
mutationFn: addTodo,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
3. Pending state
<button disabled={mutation.isPending}>Add</button>
4. Error state
{mutation.isError && <p>{mutation.error.message}</p>}
5. mutateAsync for navigation
const newTodo = await mutation.mutateAsync(title)
navigate(`/todos/${newTodo.id}`)
6. Per-call success callback
mutation.mutate(title, { onSuccess: () => setTitle('') })
7. Hook-level error callback
useMutation({ mutationFn: addTodo, onError: (e) => toast(e.message) })
8. Await invalidation in onSuccess
onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['todos'] }) }
9. Delete mutation
useMutation({
mutationFn: (id: number) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
10. Update mutation
useMutation({
mutationFn: (todo: Todo) => fetch(`/api/todos/${todo.id}`, { method: 'PUT', body: JSON.stringify(todo) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
Visual
Mutation Lifecycle and Invalidation
┌──────────────────────────────────────────────────────────────┐
│ MUTATION TRIGGERS INVALIDATION, WHICH TRIGGERS REFETCH │
│ │
│ Component │
│ └── mutation.mutate(vars) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ mutationFn runs (POST /api/todos) │ │
│ │ isPending: true │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ success │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ onSuccess fires │ │
│ │ queryClient.invalidateQueries({ queryKey: ['todos'] }) │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Query cache marks ['todos'] as stale │ │
│ │ Observed queries trigger background refetch │ │
│ │ New data arrives → component re-renders │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
mutate vs mutateAsync
┌──────────────────────────────────────────────────────────────┐
│ TWO WAYS TO TRIGGER A MUTATION │
│ │
│ mutate: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ mutation.mutate(vars) │ │
│ │ → returns undefined │ │
│ │ → state available via mutation.data/error/isPending │ │
│ │ → per-call callbacks fire for latest call only │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ mutateAsync: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const data = await mutation.mutateAsync(vars) │ │
│ │ → returns Promise<TData> │ │
│ │ → rejects on error │ │
│ │ → safe for concurrent calls (per-call promise) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Use mutate for simple UI updates. │
│ Use mutateAsync for navigation or chained operations. │
└──────────────────────────────────────────────────────────────┘
The Cache-Sync Problem
┌──────────────────────────────────────────────────────────────┐
│ WITHOUT INVALIDATION, THE CACHE IS STALE │
│ │
│ T=0: TodoList mounts, fetches ['todos'] │
│ Cache: ['todos'] = [Todo A] │
│ │
│ T=1: User adds Todo B via mutation │
│ Server: [Todo A, Todo B] │
│ Cache: ['todos'] = [Todo A] ← STALE │
│ │
│ T=2: TodoList re-renders │
│ Displays cache: [Todo A] ← Missing Todo B │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ WITH INVALIDATION: │
│ │
│ T=1: onSuccess invalidates ['todos'] │
│ Cache marked stale → background refetch runs │
│ │
│ T=2: Refetch completes │
│ Cache: ['todos'] = [Todo A, Todo B] │
│ TodoList re-renders with both todos │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Hook | useMutation |
| Required option | mutationFn |
| Trigger | mutation.mutate(vars) or mutation.mutateAsync(vars) |
| Fire-and-forget | mutate returns undefined |
| Promise-returning | mutateAsync returns Promise<TData> |
| Lifecycle callbacks | onMutate, onSuccess, onError, onSettled |
| Cache update | queryClient.invalidateQueries in onSuccess |
| Pending flag | mutation.isPending |
| Error flag | mutation.isError, mutation.error |
| Per-call callbacks | Second argument to mutate / mutateAsync |
Key takeaways:
- A mutation does not update the cache automatically. After a successful write, the related queries hold stale data until they are invalidated. The
onSuccesscallback is wherequeryClient.invalidateQueriesis called to mark them stale and trigger a refetch . mutationFnmust throw on HTTP errors. Afetchresponse with status 500 resolves successfully unless the function checksresponse.okand throws. Without this check, the mutation reports success for a failed write.mutateis fire-and-forget;mutateAsyncreturns a promise. Usemutatefor simple UI updates where the hook’s state is sufficient. UsemutateAsyncwhen the caller needs to await the result—for navigation, chaining, or per-call error handling .- Per-call callbacks fire only for the latest call. The callbacks passed as the second argument to
mutateare associated with the most recent call, and they do not fire if the component unmounts before the mutation settles. For concurrent mutations,mutateAsyncis safer because each call gets its own promise . - Invalidation is prefix-based.
invalidateQueries({ queryKey: ['todos'] })marks every query whose key starts with'todos'as stale. This is why the query key hierarchy from the earlier chapter matters: a mutation on a todo detail should invalidate the detail key and the list key . - The pending state disables the UI.
mutation.isPendingistruewhile the request is running. Disabling the submit button and showing a loading indicator prevents duplicate submissions and gives the user feedback that the write is in progress.
Remember: useMutation is the write counterpart to useQuery. It triggers an explicit request, tracks its lifecycle, and exposes callbacks for success, error, and settlement. The most important callback is onSuccess, where the related queries are invalidated so that the UI reflects the new server state. Use mutate for simple updates and mutateAsync when the result needs to be awaited. Check response.ok in the mutation function, disable the UI while pending, and always invalidate the queries that the write affects. Without invalidation, the mutation succeeds on the server but the UI continues to show the old data.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!