This episode teaches optimistic UI: updating the cache before the mutation finishes with onMutate and setQueryData, then automatic rollback in onError. You also see real use cases such as likes, status toggles, and inline forms.

When a user presses a like button, waiting half a second before seeing the icon change feels slow. The best experience: the icon changes instantly, and if the server rejects, the change is smoothly rolled back. This is an optimistic update — trusting that the operation will succeed, updating the UI first, and correcting yourself if it actually fails.
Episode 11 breaks down the optimistic update pattern in TanStack Query: update the cache in onMutate, save the old data for rollback, and restore the data when onError occurs.
An optimistic update changes the order of operations: the UI changes before the server responds. TanStack Query supports this through three useMutation callbacks:
onMutate → update the cache first (predicting success)
mutationFn → send to the server
onError → roll back the cache to the old data
onSettled → invalidate / refetch for final consistencyThe onMutate → update the cache first diagram shows the heart of this pattern: the UI is updated before mutationFn finishes. onError provides the path back if the server rejects, and onSettled cleans up the final state.
The most classic example: toggling the completed status of a todo. When the user checks the box, the UI changes immediately; if the server fails, the check is pulled back:
import { useMutation, useQueryClient } from "@tanstack/react-query"
function TodoItem({ todo }) {
const queryClient = useQueryClient()
const toggle = useMutation({
mutationFn: (newTodo) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${todo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTodo),
}).then((res) => res.json()),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] })
const previous = queryClient.getQueryData(["todos"])
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === newTodo.id ? newTodo : t))
)
return { previous }
},
onError: (error, _newTodo, context) => {
if (context?.previous) {
queryClient.setQueryData(["todos"], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] })
},
})
return (
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggle.mutate({ ...todo, completed: !todo.completed })}
/>
{todo.title}
</label>
)
}The flow is layered. onMutate cancels any running queries so they don't overwrite our update, saves previous into the context, then setQueryData immediately replaces the todo's status. onError restores previous into the cache, and onSettled invalidates so the final data always matches the server. context is the value returned from onMutate and passed to onError and onSettled.
cancelQueries cancels running refetches. Without it, a background refetch could overwrite the optimistic update with old data that doesn't reflect the change yet. await queryClient.cancelQueries({ queryKey: ["todos"] }) ensures the cache is quiet before we write new data.
Interactions like likes, bookmarks, and toggles are perfect candidates: the operation is fast, the result is certain, and instant feedback is highly appreciated by users. The pattern above can be applied directly.
Forms that can be edited in place — renaming a title, changing a description — also fit. The UI shows the new value instantly, and a rollback only happens if server validation rejects:
const rename = useMutation({
mutationFn: updateTitle,
onMutate: async ({ id, title }) => {
await queryClient.cancelQueries({ queryKey: ["todos"] })
const previous = queryClient.getQueryData(["todos"])
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === id ? { ...t, title } : t))
)
return { previous }
},
onError: (error, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(["todos"], context.previous)
}
},
})For reordering items, an optimistic update makes the item's position change instantly when the drag finishes, then the new order is sent to the server. If it fails, the order is restored to its original positions.
The key to reliable rollback is context: the value returned by onMutate is stored, then used to restore the cache in onError. Never store old data in an outer closure — context guarantees that the data being restored is exactly right for the mutation that failed.
An optimistic update must still end with consistency. onSettled runs whether the operation succeeds or fails, and the invalidateQueries there ensures the final cache always reflects the server — while also closing the door on small differences between prediction and reality.
Warning
Optimistic updates are not for every mutation. For operations whose outcome can't be reliably predicted — payments, file uploads, or server-side data transformations — it's better to show a regular loading state than to display data that turns out to be wrong.
Episode 11 gave you the complete optimistic UI pattern: update the cache in onMutate, save the old data in context, roll back in onError, and final consistency in onSettled. You also know which use cases fit and which should be avoided.
Key takeaways:
onMutate updates the cache before mutationFn finishes.cancelQueries prevents refetches from overwriting the optimistic update.context for accurate rollback.onError restores the cache to the previous data.onSettled with invalidateQueries guarantees final consistency.In the next episode, episode 12, we will discuss persistence and offline — saving the cache to localStorage or IndexedDB with a persister, and offline behavior with onlineManager and exponential backoff retry. You'll be ready to build applications that are resilient on bad networks.