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:
- 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 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`, {...})Keep building
Test loading skeletons with simulated latency
Use ?delay=N to make GoRest 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.