React 65 ⚛️ Mutations and Cache Invalidation with useMutation
A query reads data. A mutation writes it. The useMutation hook is the entry point for the create, update, and delete operations, and it is the counterpart of the useQuery hook. It takes a mutation function, it exposes the state of the operation, and it provides the callbacks for the side effects. The mutation does not touch the cache by default, and the cache invalidation is the developer’s responsibility. The useQueryClient hook provides the methods for the invalidation, the direct cache write, and the optimistic update. Getting the invalidation right is what keeps the UI consistent after a write.
Key point: A mutation does not automatically refetch the affected queries. The mutation function writes the data on the server, and the cache is unchanged. The invalidateQueries call marks the affected queries as stale, and the active queries refetch in the background. The invalidation is the bridge between the write and the read.
Why useMutation exists
The write problem. A query reads data, and the result is cached. A mutation writes data, and the cache is now out of date. The useMutation hook provides the state of the write and the callbacks for the side effects, and the invalidation is the mechanism that refreshes the cache.
The state problem. A mutation has three states: idle, pending, and settled. The isPending, isError, and isSuccess flags track them. The component renders the spinner, the error, or the success message based on the flags.
The lifecycle problem. A mutation has a lifecycle: onMutate before the request, onSuccess on the success, onError on the failure, and onSettled after both. The callbacks are the hooks for the side effects, and they are where the optimistic update and the invalidation live.
The optimistic problem. A mutation that changes the UI should show the result immediately, before the server confirms. The onMutate callback saves the current cache, writes the optimistic value, and the onError callback rolls back if the mutation fails. The onSettled callback invalidates the cache to reconcile.
The variable problem. The mutation function takes variables. The variables are passed to the mutate call, and the callbacks receive them. The variables are the input to the write.
The deduplication problem. A mutation that is called twice in the same tick fires two requests. The useMutation hook provides the mutate and the mutateAsync functions, and the mutateAsync is the promise-based form for the sequential operations.
The error problem. A mutation that fails must be handled. The onError callback receives the error, and the component renders the message. The reset method clears the state for the retry.
a. The basic mutation
The useMutation hook takes a mutation function and returns the mutation object with the mutate function and the state.
The basic usage
import { useMutation } from '@tanstack/react-query';
function AddTodo() {
const mutation = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
},
});
return (
<button onClick={() => mutation.mutate({ title: 'New todo' })}>
Add todo
</button>
);
}
The mutationFn is the write function. The mutation.mutate triggers the write with the variables.
The async mutation function
The mutation function is async and throws on error.
const mutation = useMutation({
mutationFn: async (newTodo) => {
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();
},
});
The response.ok check is required because the fetch API does not throw on HTTP errors.
The state flags
The mutation object exposes the state flags.
const { isPending, isError, isSuccess, error, data } = useMutation({ ... });
| Flag | Meaning |
|---|---|
isPending | The mutation is in progress |
isError | The mutation failed |
isSuccess | The mutation succeeded |
error | The error object |
data | The resolved value |
The mutate versus mutateAsync
The mutate function is the fire-and-forget form. The mutateAsync function returns a promise.
// mutate: no promise
mutation.mutate({ title: 'New todo' });
// mutateAsync: returns a promise
const result = await mutation.mutateAsync({ title: 'New todo' });
The mutateAsync is the form for the sequential operations, and it requires a try/catch for the error handling.
The variables
The variables are passed to the mutate call and to the callbacks.
mutation.mutate({ title: 'New todo' });
useMutation({
mutationFn: (newTodo) => { ... },
onSuccess: (data, variables, context) => {
console.log('Added:', variables.title);
},
});
The variables is the second argument of the onSuccess callback.
The reset method
The reset method clears the mutation state.
const { reset } = useMutation({ ... });
<button onClick={reset}>Reset</button>
The method is used to clear the error or the success state before the next attempt.
b. The cache invalidation
The mutation does not touch the cache. The invalidateQueries call marks the affected queries as stale, and the active queries refetch.
The basic invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}
After the mutation succeeds, the ['todos'] queries are invalidated. The active ones refetch, and the UI updates.
The prefix invalidation
The prefix matching invalidates every query whose key starts with the given prefix.
queryClient.invalidateQueries({ queryKey: ['todos'] });
The call invalidates ['todos'], ['todos', 1], and ['todos', 1, 'comments']. The hierarchical key design is the reason the invalidation is simple.
The multiple invalidations
The onSuccess callback can invalidate several queries.
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['stats'] });
},
The call marks both the list and the stats as stale.
The targeted invalidation
The queryKey with the exact key invalidates only the specific query.
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
The call invalidates the specific todo, not the whole list.
The refetchType option
The refetchType option controls which queries are refetched.
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active',
});
| Value | Meaning |
|---|---|
'active' | Refetch the active queries |
'inactive' | Refetch the inactive queries |
'all' | Refetch both |
'none' | Mark stale, do not refetch |
The 'none' value is used when the next mount should refetch, but the current one should not.
The setQueryData method
The setQueryData method writes directly to the cache without a refetch.
onSuccess: (newTodo) => {
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
},
The method is the manual cache update. It is faster than the invalidation because it does not require a network request.
The setQueryData versus the invalidation
| Aspect | setQueryData | invalidateQueries |
|---|---|---|
| Network | No | Yes (refetch) |
| Speed | Instant | Depends on the server |
| Consistency | The client writes the value | The server is the source |
| Use case | The known result | The server-computed result |
The setQueryData is the right choice when the mutation returns the updated value. The invalidateQueries is the right choice when the server computes the value.
c. The optimistic update
The optimistic update shows the result before the server confirms. The onMutate callback saves the current cache and writes the optimistic value, and the onError callback rolls back.
The basic optimistic update
const mutation = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
The onMutate saves the previous value in the context and writes the optimistic value. The onError restores the previous value. The onSettled invalidates the cache to reconcile with the server.
The cancelQueries
The cancelQueries call cancels the in-flight refetches before the optimistic update. Without it, a refetch that completes after the optimistic write overwrites the optimistic value.
await queryClient.cancelQueries({ queryKey: ['todos'] });
The context
The onMutate returns the context, and the onError and onSettled receive it.
onMutate: async (newTodo) => {
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
The context is the rollback data.
The rollback
The onError callback restores the previous value.
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
The rollback is the reason the onMutate saves the previous value.
The reconciliation
The onSettled callback invalidates the cache after both the success and the error.
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
The invalidation ensures that the cache matches the server after the optimistic update.
The optimistic update with a temporary ID
The optimistic item needs a temporary ID.
onMutate: async (newTodo) => {
const optimisticTodo = { ...newTodo, id: `temp-${Date.now()}` };
queryClient.setQueryData(['todos'], (old) => [...old, optimisticTodo]);
return { previousTodos };
},
The temporary ID is replaced by the server ID when the mutation succeeds and the cache is invalidated.
The optimistic update table
| Callback | Purpose |
|---|---|
onMutate | Cancel, save, write the optimistic value |
onError | Restore the previous value |
onSettled | Invalidate the cache |
The optimistic update anti-patterns
| Anti-pattern | Problem |
|---|---|
| No rollback | The optimistic value remains on error |
| No cancel | The in-flight refetch overwrites |
| No invalidation | The cache does not match the server |
| No temporary ID | The list key is duplicated |
| No error handler | The user does not see the failure |
Complete Example Session
// ============================================
// PART 1: THE BASIC MUTATION
// ============================================
import { useMutation } from '@tanstack/react-query';
function AddTodo() {
const mutation = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
},
});
return (
<button onClick={() => mutation.mutate({ title: 'New todo' })}>
Add todo
</button>
);
}
// ============================================
// PART 2: THE STATE FLAGS
// ============================================
function AddTodoWithState() {
const { mutate, isPending, isError, isSuccess, error } = useMutation({
mutationFn: addTodo,
});
return (
<div>
<button onClick={() => mutate({ title: 'New' })} disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
{isError && <p>Error: {error.message}</p>}
{isSuccess && <p>Added!</p>}
</div>
);
}
// ============================================
// PART 3: THE CACHE INVALIDATION
// ============================================
import { useQueryClient } from '@tanstack/react-query';
function AddTodoWithInvalidation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}
// ============================================
// PART 4: THE SETQUERYDATA
// ============================================
function AddTodoWithSetData() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: (newTodo) => {
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
},
});
return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}
// ============================================
// PART 5: THE OPTIMISTIC UPDATE
// ============================================
function OptimisticAddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <button onClick={() => mutation.mutate({ title: 'New' })}>Add</button>;
}
// ============================================
// PART 6: THE TEMPORARY ID
// ============================================
const mutation = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
const optimisticTodo = { ...newTodo, id: `temp-${Date.now()}` };
queryClient.setQueryData(['todos'], (old) => [...old, optimisticTodo]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// ============================================
// PART 7: THE EDIT MUTATION
// ============================================
function EditTodo({ todo }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (updates) => updateTodo(todo.id, updates),
onSuccess: (updated) => {
queryClient.setQueryData(['todo', todo.id], updated);
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<button onClick={() => mutation.mutate({ title: 'Updated' })}>
Edit
</button>
);
}
// ============================================
// PART 8: THE DELETE MUTATION
// ============================================
function DeleteTodo({ todoId }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => deleteTodo(todoId),
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['todo', todoId] });
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <button onClick={() => mutation.mutate()}>Delete</button>;
}
// ============================================
// PART 9: THE SEQUENTIAL MUTATIONS
// ============================================
async function handleSubmit() {
try {
const todo = await createTodoMutation.mutateAsync({ title: 'New' });
await assignTodoMutation.mutateAsync({ todoId: todo.id, userId: 42 });
} catch (error) {
console.error('Failed:', error);
}
}
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// 1. Use useMutation for the write operations.
// 2. Use the mutationFn for the fetch call.
// 3. Use the state flags for the UI.
// 4. Use invalidateQueries after the success.
// 5. Use setQueryData for the known result.
// 6. Use onMutate for the optimistic update.
// 7. Use onError for the rollback.
// 8. Use onSettled for the reconciliation.
// 9. Use mutateAsync for the sequential operations.
// 10. Use the queryClient for the cache methods.
The ten parts showed the basic mutation, the state flags, the invalidation, the setQueryData, the optimistic update, the temporary ID, the edit mutation, the delete mutation, the sequential mutations, and the complete pattern.
Quick Reference
The useMutation Options
| Option | Purpose |
|---|---|
mutationFn | The write function |
onMutate | Before the request |
onSuccess | On the success |
onError | On the failure |
onSettled | After both |
The Callbacks
| Callback | Arguments |
|---|---|
onMutate | variables |
onSuccess | data, variables, context |
onError | error, variables, context |
onSettled | data, error, variables, context |
The Returned Object
| Property | Purpose |
|---|---|
mutate | The fire-and-forget call |
mutateAsync | The promise-based call |
isPending | The mutation is in progress |
isError | The mutation failed |
isSuccess | The mutation succeeded |
error | The error object |
data | The resolved value |
reset | Clear the state |
The Cache Methods
| Method | Purpose |
|---|---|
invalidateQueries | Mark stale and refetch |
setQueryData | Write to the cache |
getQueryData | Read from the cache |
removeQueries | Remove from the cache |
cancelQueries | Cancel the in-flight refetches |
The Optimistic Update
| Callback | Purpose |
|---|---|
onMutate | Cancel, save, write |
onError | Rollback |
onSettled | Invalidate |
Best Practices
✅ Do This:
// Use the mutationFn for the write.
useMutation({ mutationFn: addTodo }); // ✅
// Use the state flags for the UI.
{isPending && <Spinner />} // ✅
// Use invalidateQueries after the success.
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }); // ✅
// Use setQueryData for the known result.
onSuccess: (newTodo) => queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); // ✅
// Use cancelQueries before the optimistic update.
await queryClient.cancelQueries({ queryKey: ['todos'] }); // ✅
// Use the context for the rollback.
onError: (err, vars, context) => queryClient.setQueryData(['todos'], context.previous); // ✅
// Use onSettled for the reconciliation.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }); // ✅
❌ Don’t Do This:
// Don't forget the invalidation.
useMutation({ mutationFn: addTodo }); // ❌ The list is stale
// Don't use the mutation without the state.
mutation.mutate(data); // ⚠️ No spinner, no error
// Don't forget the rollback.
onMutate: async () => { ... } // ❌ The optimistic value remains on error
// Don't forget the cancel.
onMutate: async () => { ... } // ❌ The in-flight refetch overwrites
// Don't forget the temporary ID.
setQueryData(['todos'], (old) => [...old, newTodo]); // ❌ The key is undefined
// Don't use mutateAsync without try/catch.
await mutation.mutateAsync(data); // ❌ The error is unhandled
// Don't invalidate everything.
invalidateQueries(); // ❌ Refetches the whole cache
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| List is stale | No invalidation | Call invalidateQueries |
| Optimistic value remains | No rollback | Restore in onError |
| Refetch overwrites | No cancelQueries | Cancel before the write |
| Duplicate key | No temporary ID | Add a temp ID |
| Unhandled error | mutateAsync without try | Use try/catch |
| Spinner does not show | No isPending | Destructure the flag |
| Cache inconsistency | No reconciliation | Use onSettled |
Real-World Examples
1. Basic Mutation
useMutation({ mutationFn: addTodo });
2. Invalidation
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] });
3. SetQueryData
onSuccess: (newTodo) => queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
4. Optimistic Update
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previous };
}
5. Rollback
onError: (err, vars, context) => queryClient.setQueryData(['todos'], context.previous);
6. Reconciliation
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] });
7. Temporary ID
const optimistic = { ...newTodo, id: `temp-${Date.now()}` };
8. Edit
onSuccess: (updated) => queryClient.setQueryData(['todo', id], updated);
9. Delete
onSuccess: () => queryClient.removeQueries({ queryKey: ['todo', id] });
10. Sequential
const todo = await createMutation.mutateAsync(data);
await assignMutation.mutateAsync({ todoId: todo.id });
Visual
The Mutation Lifecycle
┌────────────────────────────────────────────────────────────┐
│ THE MUTATION LIFECYCLE │
│ │
│ mutate(variables) │
│ ↓ │
│ onMutate(variables) │
│ → cancel the queries │
│ → save the previous value │
│ → write the optimistic value │
│ → return the context │
│ ↓ │
│ mutationFn(variables) │
│ ↓ │
│ ┌────────────────────┬────────────────────┐ │
│ │ onSuccess │ onError │ │
│ │ (data, vars, ctx) │ (err, vars, ctx) │ │
│ │ → invalidate │ → rollback │ │
│ └────────────────────┴────────────────────┘ │
│ ↓ │
│ onSettled(data, error, variables, context) │
│ → invalidate the cache │
└────────────────────────────────────────────────────────────┘
The Invalidation
┌────────────────────────────────────────────────────────────┐
│ THE INVALIDATION │
│ │
│ BEFORE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Cache: ['todos'] = [A, B] │ │
│ │ Server: [A, B, C] │ │
│ │ The cache is stale. │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ │ invalidateQueries │
│ v │
│ AFTER │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Cache: ['todos'] marked stale │ │
│ │ The active queries refetch. │ │
│ │ Cache: ['todos'] = [A, B, C] │ │
│ │ The UI updates. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Optimistic Update
┌────────────────────────────────────────────────────────────┐
│ THE OPTIMISTIC UPDATE │
│ │
│ 1. onMutate: │
│ - cancelQueries(['todos']) │
│ - previous = [A, B] │
│ - setQueryData(['todos'], [A, B, C-temp]) │
│ │
│ 2. The UI shows [A, B, C-temp] immediately. │
│ │
│ 3. The server responds: │
│ - success: onSettled invalidates, the cache is [A, B, C]│
│ - error: onError restores [A, B] │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The optimistic value is shown instantly. │ │
│ │ The rollback is automatic on error. │ │
│ │ The reconciliation is the onSettled invalidation. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The setQueryData vs invalidateQueries
┌────────────────────────────────────────────────────────────┐
│ setQueryData vs invalidateQueries │
│ │
│ setQueryData │
│ ┌──────────────────────────────────────────────────┐ │
│ │ onSuccess: (newTodo) => { │ │
│ │ queryClient.setQueryData(['todos'], │ │
│ │ (old) => [...old, newTodo]); │ │
│ │ } │ │
│ │ │ │
│ │ No network request. Instant. │ │
│ │ Use when the mutation returns the value. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ invalidateQueries │
│ ┌──────────────────────────────────────────────────┐ │
│ │ onSuccess: () => { │ │
│ │ queryClient.invalidateQueries({ │ │
│ │ queryKey: ['todos'] }); │ │
│ │ } │ │
│ │ │ │
│ │ A refetch is triggered. │ │
│ │ Use when the server computes the result. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
useMutation | The write hook |
mutationFn | The write function |
mutate | The fire-and-forget call |
mutateAsync | The promise-based call |
onMutate | Before the request |
onSuccess | On the success |
onError | On the failure |
onSettled | After both |
invalidateQueries | Mark stale and refetch |
setQueryData | Write to the cache |
Key takeaways:
- The
useMutationhook is the write counterpart ofuseQuery. It takes the mutation function, it exposes the state flags, and it provides the callbacks for the side effects. The mutation does not touch the cache by default. - The
invalidateQueriescall is the bridge between the write and the read. After the mutation succeeds, the affected queries are marked stale, and the active queries refetch. The prefix matching invalidates the hierarchical keys. - The
setQueryDatamethod writes the known result directly. The method is faster than the invalidation because it does not require a network request. The invalidation is the right choice when the server computes the result. - The optimistic update shows the result before the server confirms. The
onMutatecallback cancels the in-flight queries, saves the previous value, and writes the optimistic value. TheonErrorcallback rolls back, and theonSettledcallback invalidates. - The
cancelQueriescall prevents the overwrite. An in-flight refetch that completes after the optimistic write overwrites the optimistic value. The cancel is the fix. - The temporary ID is required for the optimistic item. The list key must be unique. The
temp-${Date.now()}ID is replaced by the server ID when the cache is invalidated. - The
mutateAsyncfunction is the sequential form. It returns a promise, and it requires atry/catchfor the error handling. Themutatefunction is the fire-and-forget form. - The callbacks receive the context. The
onMutatereturns the context, and theonErrorandonSettledreceive it. The context is the rollback data.
Remember: The useMutation hook is the entry point for the write operations in a TanStack Query application. The mutationFn writes the data, the state flags tell the component what to render, and the callbacks handle the side effects. The invalidateQueries call marks the cache stale, and the active queries refetch. The setQueryData method writes the known result, and the optimistic update shows the result before the server confirms. The cancelQueries call prevents the overwrite, the temporary ID keeps the list key unique, and the onSettled invalidation reconciles the cache with the server. The result is an application where the writes update the UI instantly, the cache stays in sync, and the errors roll back the optimistic changes.
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!