How to Build Optimistic Updates in React

Build optimistic updates in React with TanStack Query. Apply the change before the server responds, roll back on error, and refetch on success.

7 min read

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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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

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.
RunePowered by Rune AI

Frequently Asked Questions

What is an optimistic update?

It applies a change to the UI before the server confirms it, assuming the request will succeed. If the request fails, the UI rolls back to the previous state.

Why not just wait for the server?

Waiting shows a spinner on every write. Optimistic updates feel instant, which matters for small, frequent actions like toggling or adding items.

What happens if the mutation fails?

The onError callback restores the snapshot taken in onMutate, so the optimistic change is undone. The onSettled callback then refetches so the UI matches the server.

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.