When a user toggles a checkbox, likes a post, or reorders a list, waiting for the server to confirm the change before updating the UI feels sluggish. The user clicked something - they want to see it respond immediately.
An optimistic update means you update the UI as if the request already succeeded, then confirm or roll back once the server responds. If the server says it worked, nothing visible changes - the UI was already correct. If the server returns an error, you undo the change and show an error message.
This is not appropriate for every action. Submitting a payment, deleting an account, or sending a message are cases where the server's confirmation matters before you tell the user it worked. But for low-stakes, reversible interactions - toggles, likes, reorders - it's a genuine UX improvement.
onMutate, onError, onSettledTanStack Query's useMutation has three lifecycle callbacks that make optimistic updates clean to implement:
| Callback | When it runs | What to do |
|---|---|---|
onMutate |
Before the request fires | Update the cache optimistically; return a context object with the previous state |
onError |
When the request fails | Roll back to the previous state using the context |
onSettled |
After success or error | Invalidate queries to sync with the server |
Imagine a todo list where each item has a checkbox. Clicking it should feel instant.
type Todo = {
id: number
title: string
completed: boolean
}
Without optimistic updates, the toggle waits for the server:
invalidateQueries firesWith optimistic updates, step 1 and step 5 happen simultaneously.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient()
const toggleMutation = useMutation({
mutationFn: (updated: Todo) =>
fetch(`/api/todos/${updated.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: updated.completed }),
}).then(res => res.json()),
onMutate: async (updatedTodo) => {
// 1. Cancel any in-flight refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot the current value so we can roll back if needed
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map(t => t.id === updatedTodo.id ? updatedTodo : t) ?? []
)
// 4. Return the snapshot in context - onError receives this so it know how to rollback
return { previousTodos }
},
onError: (_err, _updatedTodo, context) => {
// The request failed - restore the previous state
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
// Whether it succeeded or failed, sync with the server
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
function handleToggle() {
toggleMutation.mutate({ ...todo, completed: !todo.completed })
}
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={handleToggle}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
</span>
</li>
)
}
Walk through what happens when the user checks the box:
onMutate runs - the cache is updated immediately with completed: true, so the checkbox checks and the text gets a strikethrough before the request finishesPATCH request goes outonSettled fires, invalidateQueries triggers a background refetch to confirm the server state matchesonError fires, setQueryData restores previousTodos, the checkbox unchecks itself, and onSettled still fires to sync<aside> 💡
cancelQueries in onMutate is important. Without it, a background refetch that was already in flight could complete after your optimistic update and overwrite it with the old data, briefly reverting the UI before the mutation completes.
</aside>
The rollback happens in onError. The previous state is available because onMutate returned it in the context object - TanStack Query passes that context through to onError automatically.
// error - the thrown error from mutationFn
// variables - what you passed to mutate()
// context - whatever onMutate returned
onError: (error, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
// You can also show a toast or error message here
console.error('Toggle failed:', error.message)
},
Always type the context if you want TypeScript to know its shape. You can do this by typing the mutation:
useMutation<Todo, Error, Todo, { previousTodos: Todo[] | undefined }>({
// ...
})
<aside> ⚠️
If your server can reject the mutation for reasons you can't predict on the client (validation errors, permissions, concurrency conflicts), make sure your rollback path is always correct. A half-reverted UI is worse than a slow one.
</aside>
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.