Build optimistic updates in React by updating the UI before the server responds, then rolling back if the request fails. TanStack Query's useMutation gives you onMutate, onError, and onSettled callbacks to apply, revert, and reconcile the change. This guide builds the add-item flow with those callbacks.
Why update before the server responds
Optimistic means the UI assumes success instead of waiting. A plain mutation shows a spinner until the server replies, which can feel slow even on a fast connection. An optimistic update applies the change immediately and only corrects it if something fails.
The risk is that the assumption can be wrong, so every optimistic update needs a rollback path. TanStack Query structures that path with three callbacks that run before, on failure, and after the mutation.
You can do the same with plain state, but the library centralizes the rollback in one place. The cost is a little extra bookkeeping, which the callbacks below handle.
Apply the change in onMutate
The onMutate callback runs before the request, so it is the right place to write the optimistic value into the cache. It receives the variables passed to mutate, the same object the mutationFn will use. First cancel any in-flight refetches so they do not overwrite the change, then snapshot the current value for later rollback.
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previousTodos = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
return { previousTodos };
},The callback returns an object with the snapshot. TanStack Query passes that object to the error and settled callbacks later.
The list now shows the new item immediately, before the server has confirmed anything. This is the cache approach. An alternative is to skip the cache and render a temporary item from the mutation's pending variables, which works when only one component shows the list.
Roll back in onError
If the request fails, the optimistic change was wrong, and the temporary item must come back out. The onError callback restores the snapshot so the list returns to its previous state.
onError: (error, newTodo, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},The context argument is the object returned from onMutate, which is the snapshot captured earlier. Restoring it removes the temporary item.
Without this step, a failed request would leave a ghost item in the list that the server never accepted. Rollback is more reliable than a refetch when the failure is a server problem, because a refetch might fail the same way. The snapshot restores the exact previous list without another request.
Reconcile in onSettled
The onSettled callback runs after success or failure. It is the right place to invalidate the query, which refetches the list so the cache matches what the server actually stored.
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},After a success, the refetch replaces the optimistic item with the server's version, including any id or fields the server generated. After a failure, the snapshot is already restored, and the refetch simply confirms it.
Using onSettled instead of separate success and error handlers keeps the reconciliation in one place, since both outcomes need the same refetch. The add, invalidate, and refetch flow is the same one explained in how to mutate data and invalidate queries with TanStack Query.
Wire the mutation to a button
The mutation object combines the three callbacks, and a submit handler calls mutate with the new item. The list component reads the same query key, so it updates on its own through the cache.
function handleSubmit(e) {
e.preventDefault();
mutation.mutate({ id: crypto.randomUUID(), title });
}The temporary id keeps list keys unique until the server returns a real one. Generating a temporary id client-side is fine, because the server replaces it on the refetch.
On rollback the temporary item is removed, and on success the refetch replaces it. The cache behavior behind setQueryData is covered in useQuery explained, and the loading and error states that wrap the whole screen are in how to handle loading, error, empty, and success states.
Rune AI
Key Insights
- Apply the change in onMutate before the request resolves.
- Snapshot the previous cache value for rollback.
- Restore the snapshot in onError.
- Invalidate the query in onSettled to reconcile.
- Cancel in-flight refetches before applying the change.
Frequently Asked Questions
What is an optimistic update?
Why not just wait for the server?
What happens if the mutation fails?
Conclusion
Apply the change in onMutate with a saved snapshot, restore that snapshot in onError, and invalidate the query in onSettled. The three callbacks give the UI an instant update with a safe fallback.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.