← Back to Guides
8 min readIntermediate
Share

Optimistic UI Updates Without a State Library

Make the interface respond instantly and reconcile with the server afterwards — including the rollback path everyone forgets. Plain React, no dependencies.

Optimistic UI Updates Without a State Library

There's a specific kind of cheapness to an app where clicking a checkbox shows a spinner for 400ms. Nothing is broken. It just feels like software from 2011.

The fix is optimistic updates: apply the change to local state immediately, fire the request in the background, and reconcile when it lands. It's a small pattern, and you don't need React Query or a state library to do it. What you do need is the part most implementations skip — a correct rollback.

The naive version, and why it's wrong

Here's what most people write first:

async function toggle(id: string) {
  setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  await fetch(`/api/todos/${id}/toggle`, { method: "POST" });
}

This is optimistic, and for the happy path it's correct. It has three bugs waiting for a bad network:

  1. No rollback. The request 500s and the UI keeps showing the change. The user believes their edit saved. They close the tab.
  2. No error surface. Even if you catch, nobody told the user anything.
  3. Races. Toggle twice quickly and two requests are in flight. If they resolve out of order — which they will, eventually — the final state is whichever one the server happened to finish last, not what the user clicked last.

Each one is a few lines to fix. All three matter.

Rollback: snapshot, don't invert

The instinct is to undo by applying the inverse operation. Don't. Inverses are only correct if nothing else changed in between, and something else always changes in between.

Snapshot the previous state and restore it wholesale:

async function toggle(id: string) {
  const snapshot = todos;                     // capture before mutating
  setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));

  try {
    const res = await fetch(`/api/todos/${id}/toggle`, { method: "POST" });
    if (!res.ok) throw new Error(`Server said ${res.status}`);
  } catch (err) {
    setTodos(snapshot);                       // restore, don't un-toggle
    setError("Couldn't save that — try again.");
  }
}

snapshot closes over the render's todos, so it's the exact list the user was looking at when they clicked. Restoring it is one assignment and it's always right.

The cost of the snapshot approach is that it also discards other optimistic changes made after this one — if a user toggles item A, then item B, and A fails, B's change is thrown away too. For most apps this is acceptable and arguably correct: showing the last known-good state is honest. If it isn't acceptable for yours, jump to the pending-map version below.

Races: last write wins, by request

Rapid clicks produce concurrent requests. The one that resolves last shouldn't automatically be the one that decides state — the one that started last should.

The cheapest correct fix is a per-item sequence number:

const seq = useRef(new Map<string, number>());

async function toggle(id: string) {
  const ticket = (seq.current.get(id) ?? 0) + 1;
  seq.current.set(id, ticket);

  const snapshot = todos;
  setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));

  try {
    const res = await fetch(`/api/todos/${id}/toggle`, { method: "POST" });
    if (!res.ok) throw new Error(String(res.status));
  } catch {
    // A newer toggle for this item superseded us — its own handler owns the outcome.
    if (seq.current.get(id) !== ticket) return;
    setTodos(snapshot);
    setError("Couldn't save that — try again.");
  }
}

The guard says: only the most recent request for this item is allowed to roll back. A stale failure from a superseded request is ignored, because whatever the user did afterwards is the truth.

If your endpoint is a toggle rather than a set, consider changing it to accept the target value (PATCH { done: true }) instead. Idempotent writes make the whole class of ordering problems disappear, and that's a smaller fix than any client-side sequencing.

Pending state, without flicker

Optimistic doesn't mean invisible. The user should be able to tell that something is still in flight, without the UI jumping around.

Track pending IDs in a set and use it for subtle styling only — reduced opacity, a disabled control — never for layout changes:

const [pending, setPending] = useState<Set<string>>(new Set());

const mark = (id: string, on: boolean) =>
  setPending((prev) => {
    const next = new Set(prev);
    if (on) next.add(id);
    else next.delete(id);
    return next;
  });
<li className={pending.has(todo.id) ? "opacity-60 transition-opacity" : "transition-opacity"}>

Two rules that keep this from feeling worse than a spinner:

  • Don't disable the control. If the user wants to toggle again while a request is in flight, let them — the sequencing above handles it. Disabling makes a fast app feel slow at exactly the moment it's trying not to.
  • Don't animate in. A pending style that fades in after 100ms reads as lag. If you must delay, delay showing it (only show pending after ~500ms), not the transition itself.

Creating items: the temporary ID problem

Updates are easy because the ID already exists. Creates aren't — you need something to render before the server has assigned an ID.

async function addTodo(text: string) {
  const tempId = `temp-${crypto.randomUUID()}`;
  const optimistic = { id: tempId, text, done: false };
  setTodos((prev) => [...prev, optimistic]);

  try {
    const res = await fetch("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text }),
    });
    if (!res.ok) throw new Error(String(res.status));
    const saved = await res.json();
    // Swap in place — don't remove and append, or the row jumps.
    setTodos((prev) => prev.map((t) => (t.id === tempId ? saved : t)));
  } catch {
    setTodos((prev) => prev.filter((t) => t.id !== tempId));
    setError("Couldn't add that one.");
  }
}

Three details:

  • crypto.randomUUID() is built into every current browser — no uuid package needed.
  • The temp- prefix means you can cheaply check id.startsWith("temp-") to disable actions that need a real server ID (share links, permalinks).
  • Swap the item in place rather than filtering and appending. Removing and re-adding causes a visible reorder and destroys the DOM node, which kills any in-flight CSS transition and loses focus if the user was editing it.

When React's own hook is enough

React ships useOptimistic for this, and for a form submission wrapped in a Server Action or a transition, it's less code than anything above:

const [optimisticTodos, addOptimistic] = useOptimistic(
  todos,
  (state, newTodo: Todo) => [...state, newTodo]
);

The catch is its lifecycle: the optimistic value is discarded automatically when the surrounding transition finishes, and it re-derives from the real state. That's exactly right for form submissions — rollback is free, you just render the unchanged server state — and it's awkward for anything that isn't one, because you don't control when the optimistic layer drops.

Rough rule: form submit inside a transition or Server Action, reach for useOptimistic. A toggle, a drag reorder, an inline edit, or anything where you need explicit control over the rollback and the error message, write the fifteen lines.

Where optimistic updates are the wrong call

Don't be optimistic about operations where being wrong is expensive or embarrassing:

  • Payments and anything irreversible. "Payment sent!" followed by a rollback is not a UX bug, it's a support ticket.
  • Operations with server-side validation you can't replicate. If the server might reject the change for a reason the client can't predict — a uniqueness constraint, a permission check, a quota — an optimistic success is a lie you'll have to retract about a third of the time.
  • Slow operations that aren't really writes. If it takes four seconds because work is happening, show the work. Optimism is for hiding network latency, not compute.

The test: if the failure rate is under a percent or two and the rollback is invisible, be optimistic. Otherwise show the real state and make the request fast instead.

Related: State management · Error boundaries and graceful failure · Debounce and throttle in React

Get the good stuff

New tools and posts, occasionally. No spam.