Skip to content
Innovialabs

Optimistic UI Updates in React: Patterns and Rollback

Advanced5 min

In short

Optimistic UI shows the expected result immediately and syncs with the server in the background; it needs a snapshot for rollback, protection from races and duplicates, and should never be used where a false "success" costs money.

What optimistic UI is and when it pays off

Optimistic UI means changing the interface as if the request has already succeeded, then confirming or reverting it when the server answers. A like turns red instantly, a todo appears in the list without a spinner, a renamed file shows its new name right away.

It works well when three conditions hold:

  • the operation almost always succeeds (validation already passed on the client);
  • the result is easy to predict from the input;
  • a failure is cheap to undo visually and does not mislead the user about something important.

If any of these is false, show an honest pending state instead.

useOptimistic: the built-in React approach

React 19 ships the useOptimistic hook. It returns a temporary state that exists only while an action or transition is running; when it finishes, React switches back to the real state you passed in.

import { useOptimistic } from "react";

function TodoList({ todos, addTodo }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (current, draft) => [...current, { ...draft, pending: true }]
  );

  async function formAction(formData) {
    const draft = { id: crypto.randomUUID(), text: formData.get("text") };
    addOptimistic(draft);
    try {
      await addTodo(draft); // must update `todos` in the parent on success
    } catch {
      showToast("Could not save the task");
    }
  }

  return (
    <form action={formAction}>
      <input name="text" required />
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
        ))}
      </ul>
    </form>
  );
}

Key points:

  • Rollback is automatic: if the action fails, the optimistic item disappears as soon as the transition ends, because the real todos never changed.
  • The flip side: on success, the real state must also update (revalidation, setState in the parent, a Server Action with revalidatePath). Otherwise the item vanishes after the action completes.
  • addOptimistic must be called inside an action or startTransition, not in an ordinary event handler.

TanStack Query: snapshot, update, rollback

When data lives in the TanStack Query cache, the classic pattern is built on onMutate:

const queryClient = useQueryClient();

const addTodo = useMutation({
  mutationFn: (todo: Todo) => api.addTodo(todo),
  onMutate: async (todo) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    const previous = queryClient.getQueryData<Todo[]>(["todos"]);
    queryClient.setQueryData<Todo[]>(["todos"], (old = []) => [...old, todo]);
    return { previous };
  },
  onError: (_err, _todo, ctx) => {
    queryClient.setQueryData(["todos"], ctx?.previous);
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});

The steps:

  1. Cancel in-flight queries so an older refetch does not overwrite the optimistic data.
  2. Save a snapshot of the cache.
  3. Write the expected result into the cache.
  4. On error, restore the snapshot and tell the user.
  5. In any case, refetch to get the source of truth.

A simpler option: skip the cache entirely and render mutation.variables as a semi-transparent item while isPending is true. Nothing to roll back — on error the item just disappears.

Race conditions and duplicates

Optimistic UI makes concurrency problems more visible. Typical cases:

ProblemWhat happensHow to handle
Stale refetchA query started before the mutation returns old datacancelQueries before the update
Parallel mutationsRolling back one restores a snapshot that erases anotherUndo only your own change (filter by id), not the whole snapshot
Out-of-order responsesThe answer to the first click arrives after the secondSend the final state ("liked: true"), not a delta; compare versions
Early invalidationRefetch after the first mutation wipes the others' optimistic dataInvalidate only when isMutating for that key drops to one
Double submitThe user clicks again because "it already worked"Client-generated id or idempotency key, server deduplicates

Client-generated IDs solve two problems at once: there is no need to swap a temporary id for the real one, and a repeated request with the same id is easy to deduplicate on the backend.

Where optimistic UI is dangerous

Do not pretend success when a mistake has real consequences:

  • payments, transfers, balance top-ups — the user may close the tab believing the money has been sent;
  • orders with limited stock and bookings, where the server decides availability;
  • irreversible actions: sending an email, publishing, permanent deletion;
  • operations with complex server-side validation whose outcome cannot be predicted.

For these, use an explicit pending state: a disabled button, a progress indicator, and a confirmation screen only after the server responds.

Common mistakes

  • No error message: the item quietly disappears and the user does not understand why.
  • Rolling back the whole cache in parallel mutations and losing other people's changes.
  • Forgetting the final refetch, so the client and server drift apart.
  • Optimistic deletion without an "Undo" option for important data.

FAQ

Use useOptimistic when data comes through Server Components or local state and changes via actions. If the app already keeps server data in the TanStack Query cache, update that cache in onMutate, so all components see the same state.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.