Testing Optimistic Updates and Rollback
An optimistic update shows the result of an action before the server has confirmed it: the item is ticked, the like count rises, the card moves to the new column — instantly. It makes an interface feel fast, and it creates a promise the code must keep: if the server says no, the interface must undo the change and tell the user, without losing anything else that happened in the meantime. Most optimistic updates are tested only on the path where the server says yes, which is the path least likely to go wrong. This guide covers testing all three phases — the instant change, the confirmation, and the rollback — plus concurrent mutations and the final reconciliation, with TanStack Query and MSW. It sits under data fetching and cache testing.
Root Cause Analysis
An optimistic update is three pieces of code that must agree. onMutate snapshots the current cache and writes the optimistic value. onError restores the snapshot. onSettled invalidates the query so the server’s truth replaces whatever the cache holds. Each is easy to write and easy to get subtly wrong, and only the first runs on the happy path.
The characteristic bugs are all on the failure path. onError restores a snapshot taken before an earlier, still-pending mutation, undoing that one too. The snapshot is taken by reference, so the “restored” value is the already-mutated object. The error is swallowed, so the item silently reverts and the user never learns why their change disappeared. None of these shows up if the server always succeeds.
Concurrency adds a further class. A user who toggles two items quickly has two mutations in flight; if the first fails, rolling back must restore only the first item. And an in-flight refetch that lands between the optimistic write and the server’s response can overwrite the optimistic value with stale data, making the change appear to undo itself briefly. Cancelling outgoing queries in onMutate prevents that, and a test that holds a refetch open proves it.
Reproducible Setup
A to-do list where toggling an item is optimistic, using the standard TanStack Query pattern.
// src/todos/useToggleTodo.ts
export function useToggleTodo() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, done }: { id: string; done: boolean }) =>
fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ done }) }).then((r) => {
if (!r.ok) throw new Error('toggle failed');
}),
onMutate: async ({ id, done }) => {
await client.cancelQueries({ queryKey: ['todos'] });
const previous = client.getQueryData<Todo[]>(['todos']);
client.setQueryData<Todo[]>(['todos'], (old = []) => old.map((t) => (t.id === id ? { ...t, done } : t)));
return { previous, id };
},
onError: (_err, _vars, ctx) => {
client.setQueryData<Todo[]>(['todos'], (current = []) =>
current.map((t) => (t.id === ctx?.id ? ctx.previous!.find((p) => p.id === t.id)! : t)));
toast.error('That change could not be saved.');
},
onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }),
});
}
Restoring only the affected item, rather than the whole snapshot, is what keeps a failure from undoing other in-flight changes — the concurrency test below proves it.
Implementation
Step 1 — Assert the instant change before the server answers. Hold the PATCH open, click, and assert the checkbox is already ticked.
// src/todos/TodoList.test.tsx
test('ticks the item immediately, before the server responds', async () => {
const gate = deferred<void>();
server.use(
http.get('/api/todos', () => HttpResponse.json([{ id: 't1', title: 'Write tests', done: false }])),
http.patch('/api/todos/t1', async () => { await gate.promise; return new HttpResponse(null, { status: 204 }); }),
);
const user = userEvent.setup();
renderWithQuery(<TodoList />);
const box = await screen.findByRole('checkbox', { name: 'Write tests' });
await user.click(box);
expect(box).toBeChecked(); // optimistic — the PATCH is still pending
gate.resolve();
});
Step 2 — Confirm the change survives the server’s success and reconciliation. After the server confirms and the list refetches, the item stays ticked.
test('keeps the change once the server confirms', async () => {
let done = false;
server.use(
http.get('/api/todos', () => HttpResponse.json([{ id: 't1', title: 'Write tests', done }])),
http.patch('/api/todos/t1', async ({ request }) => { done = (await request.json()).done; return new HttpResponse(null, { status: 204 }); }),
);
const user = userEvent.setup();
renderWithQuery(<TodoList />);
await user.click(await screen.findByRole('checkbox', { name: 'Write tests' }));
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Write tests' })).toBeChecked());
});
Step 3 — Roll back on failure and tell the user. The checkbox returns to its previous state and a message explains why.
test('reverts and explains when the server rejects the change', async () => {
server.use(
http.get('/api/todos', () => HttpResponse.json([{ id: 't1', title: 'Write tests', done: false }])),
http.patch('/api/todos/t1', () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
renderWithQuery(<><TodoList /><Toaster /></>);
const box = await screen.findByRole('checkbox', { name: 'Write tests' });
await user.click(box);
await waitFor(() => expect(box).not.toBeChecked());
expect(await screen.findByRole('status')).toHaveTextContent('That change could not be saved.');
});
Step 4 — Test concurrent mutations where only one fails. Rolling back the failed item must leave the successful one alone.
test('a failed toggle does not undo another in-flight toggle', async () => {
server.use(
http.get('/api/todos', () => HttpResponse.json([
{ id: 't1', title: 'Write tests', done: false },
{ id: 't2', title: 'Ship it', done: false },
])),
http.patch('/api/todos/t1', async () => { await delay(50); return new HttpResponse(null, { status: 500 }); }),
http.patch('/api/todos/t2', async () => { await delay(100); return new HttpResponse(null, { status: 204 }); }),
);
const user = userEvent.setup();
renderWithQuery(<TodoList />);
await user.click(await screen.findByRole('checkbox', { name: 'Write tests' }));
await user.click(screen.getByRole('checkbox', { name: 'Ship it' }));
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Write tests' })).not.toBeChecked());
expect(screen.getByRole('checkbox', { name: 'Ship it' })).toBeChecked();
});
Step 5 — Prove outgoing refetches are cancelled. Hold a refetch open, apply the optimistic change, release the stale response, and assert the optimistic value is not overwritten.
Written out, that test holds the list refetch with a deferred response, clicks the checkbox, releases the refetch with the old unticked data, and asserts the checkbox stays ticked. Without the cancellation in onMutate, the stale response lands after the optimistic write and the item briefly unticks — a flicker users notice and report as the interface “not saving”, even though the server eventually agrees.
Step 6 — Assert the final refetch reconciles. If the server returns something different from the optimistic guess — a normalised title, a server timestamp — the settled refetch should replace the optimistic value with the server’s version.
The reconciliation test is also where the optimistic guess is checked against reality. An optimistic update is a prediction of what the server will return, and predictions drift: the server starts trimming whitespace, adding a completion timestamp, reordering the list. Asserting that the settled state shows the server’s version, not the guess, is what stops those small divergences from accumulating into an interface that disagrees with the database until the next page load.
Verification
Confirm the concurrency test guards the right bug by changing onError to restore the whole snapshot. The “Ship it” assertion must fail, which is exactly the regression users would experience as a change undoing itself.
npx vitest run src/todos/TodoList.test.tsx --reporter=verbose
# ✓ ticks the item immediately, before the server responds
# ✓ keeps the change once the server confirms
# ✓ reverts and explains when the server rejects the change
# ✓ a failed toggle does not undo another in-flight toggle
Then confirm the rollback message is not optional: remove the toast from onError. The rejection test must fail on the status assertion, since a silent revert is a defect in its own right.
Troubleshooting
Symptom: the optimistic change flickers back and forth. Diagnosis: a refetch in flight when the mutation started lands with stale data. Fix: await client.cancelQueries(...) at the start of onMutate, and add the Step 5 test to keep it there.
Symptom: the rollback restores the wrong value. Diagnosis: the snapshot was the same object that was then mutated in place. Fix: update immutably in setQueryData, so the snapshot remains the previous value.
Symptom: the rejection test passes even without onError. Diagnosis: onSettled refetches and restores the server’s state anyway, so the checkbox reverts regardless. Fix: assert the message as well, and hold the refetch open to check the rollback happens before it.
Symptom: the concurrent test is flaky. Diagnosis: relying on relative delays that the machine may compress. Fix: use deferred responses and release them in a chosen order rather than racing two delay calls.
FAQ
When is optimistic UI the wrong choice?
When failure is common or consequential — payments, irreversible deletions, anything a user would be alarmed to see revert. Optimistic updates suit frequent, low-stakes actions whose failure is rare and easily retried.
Should optimistic updates be tested end to end?
One end-to-end test that the action works is enough. The failure and concurrency cases need precise control over response order and outcome, which the component tier provides and a real server does not.
How does this work with SWR?
SWR’s mutate accepts optimisticData and rollbackOnError, which cover the snapshot and restore steps. The four tests above apply unchanged, as the SWR guide’s final step shows in testing SWR revalidation behaviour.
What about React 19’s useOptimistic?
It handles the local optimistic state and reverts automatically when the action settles. The same tests apply — instant change, kept on success, reverted with a message on failure — with the difference that the rollback logic is React’s, so the concurrency test verifies your action’s error handling rather than a hand-written onError.
Related
- Back to Data Fetching & Cache Testing
- Testing React Query hooks with a fresh cache — the client setup these tests use.
- Testing form error recovery and retry — non-optimistic failure handling.
- Testing database transactions and rollbacks — atomicity on the server side of the same change.