Skip to content

Add optimistic updates without races

Render the new state before the server confirms; roll back on failure. Pattern works for create / update / delete.

Optimistic updates are what make a UI feel native. The user clicks a checkbox, the row updates instantly, and the request goes out in the background. If the server agrees, nothing visible happens. If it rejects, the change rolls back. Done well, the latency disappears entirely. Done poorly, you confuse users about what is real.

The naive (pessimistic) version

Most code starts here: send the request, wait for the response, then update the UI. Easy to write, but every action has the round-trip latency built in.

async function toggleStatus(id, current) {
  setBusy(true);
  const next = current === "active" ? "inactive" : "active";
  const res = await fetch(`/public/v2/users/${id}`, {
    method: "PATCH",
    headers,
    body: JSON.stringify({ status: next })
  });
  if (res.ok) {
    const updated = await res.json();
    setUsers(users => users.map(u => u.id === id ? updated : u));
  }
  setBusy(false);
}

Average API latency on a public service like GoRest is 100-300 ms. That much delay between a click and a UI change feels broken on every fast network. The fix is to update the UI first.

The optimistic version

Four steps:

  1. Apply optimistically. Update the local state as if the request already succeeded.
  2. Send the request. Fire the API call.
  3. Reconcile. When the response arrives, replace the optimistic copy with the server's truth (in case any field changed).
  4. Roll back on failure. If the server rejects, restore the previous state and tell the user.
async function toggleStatus(id, current) {
  const next = current === "active" ? "inactive" : "active";
  const previous = users.find(u => u.id === id);

  // 1. Apply optimistically
  setUsers(users => users.map(u =>
    u.id === id ? { ...u, status: next } : u
  ));

  try {
    // 2. Send to server
    const res = await fetch(`/public/v2/users/${id}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify({ status: next })
    });
    if (!res.ok) throw new Error(`Got ${res.status}`);

    // 3. Reconcile with the server's truth
    const updated = await res.json();
    setUsers(users => users.map(u => u.id === id ? updated : u));
  } catch (err) {
    // 4. Roll back on failure
    setUsers(users => users.map(u => u.id === id ? previous : u));
    showToast("Could not save; reverted.");
  }
}

Notice we capture previous before changing the state; if the request fails, we put it back. The toast on rollback is essential: users need to know their action did not stick.

Optimistic create

The hard case. The new row needs an id before the server has issued one. Use a temporary id, and swap it for the real one when the response arrives.

async function createUser(input) {
  const tempId = `temp-${Date.now()}`;
  const optimistic = { id: tempId, ...input, _pending: true };

  setUsers(prev => [optimistic, ...prev]);

  try {
    const res = await fetch("/public/v2/users", {
      method: "POST", headers, body: JSON.stringify(input)
    });
    if (!res.ok) throw new Error(`Got ${res.status}`);

    const real = await res.json();
    setUsers(prev => prev.map(u => u.id === tempId ? real : u));
  } catch (err) {
    setUsers(prev => prev.filter(u => u.id !== tempId));
    showToast("Could not create user.");
  }
}

The _pending flag is optional but useful: render the row at half opacity until the server confirms, so the user knows it is not fully saved.

Optimistic delete

Easy: remove from the list, restore on error.

async function deleteUser(id) {
  const previous = users;
  setUsers(prev => prev.filter(u => u.id !== id));

  try {
    const res = await fetch(`/public/v2/users/${id}`, {
      method: "DELETE", headers
    });
    if (!res.ok && res.status !== 404) throw new Error();
  } catch (err) {
    setUsers(previous); // restore everything
    showToast("Delete failed; restored.");
  }
}

Treat 404 as success: the row is gone, which is what we wanted.

With React Query

If you are using React Query / TanStack Query, optimistic updates are built in. Define onMutate, onError, and onSettled. The library handles the rollback wiring.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useToggleStatus() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, status }) => fetch(`/public/v2/users/${id}`, {
      method: "PATCH", headers, body: JSON.stringify({ status })
    }).then(r => r.json()),

    onMutate: async ({ id, status }) => {
      await qc.cancelQueries({ queryKey: ["users"] });
      const previous = qc.getQueryData(["users"]);
      qc.setQueryData(["users"], (rows) =>
        rows.map(r => r.id === id ? { ...r, status } : r)
      );
      return { previous };
    },

    onError: (err, vars, ctx) => {
      qc.setQueryData(["users"], ctx.previous);
    },

    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["users"] });
    }
  });
}

The race condition

Optimistic updates can race. The user clicks a toggle twice fast, the second click sees the optimistic state of the first, and what happens? Three options:

  • Disable controls while busy. Simplest. The user cannot click twice.
  • Cancel the previous request. Use AbortController; whichever finishes last wins.
  • Sequence them. Queue updates and send them one at a time.

For most UIs, option 1 (disable while busy) is enough. Option 3 is right for collaborative editors and similar tools.

When NOT to be optimistic

  • Anything destructive that needs confirmation. Deleting an account is not optimistic; the user agrees first.
  • Anything where partial state would mislead. Bank transfers, payments, anything legally binding: wait for the real response.
  • When the server commonly rejects. If 30% of submissions fail validation, optimistic UI flickers a lot. Pessimistic is calmer.

Test the rollback path

The whole point of optimistic UI is that errors are rare and rollback is rarer. That makes it easy to ship a broken rollback. Use GoRest's ?force_status=500 parameter to trigger failures on purpose and confirm your rollback restores correctly:

fetch(`/public/v2/users/${id}?force_status=500`, {...})
More recipes

Keep building