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, 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 baked 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 Go REST 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:
- Apply optimistically. Update the local state as if the request already succeeded.
- Send the request. Fire the API call.
- Reconcile. When the response arrives, replace the optimistic copy with the server's truth (in case any field changed).
- 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 captureprevious before mutating; 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, 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 first-class. DefineonMutate,onError, andonSettled. 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. User clicks toggle twice fast, the second click sees the optimistic state of the first, and what happens? Three options:
- Disable controls while busy. Simplest. 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 plenty. 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 Go REST's?force_status=500 parameter to systematically trigger failures and confirm your rollback restores correctly:
fetch(`/public/v2/users/${id}?force_status=500`, {...})Keep building
Test loading skeletons with simulated latency
Use ?delay=N to make Go REST take seconds to respond, so you can verify your skeleton screens render without flicker.
Test 401, 422, 429 and 500 handlers
Force any status code with ?force_status= to make sure your error UI never shows blank screens or eats stack traces.
Build a paginated user list in React
A working component that fetches a page, renders rows, and uses X-Pagination-Pages to render Previous/Next correctly.