The exact problem: a form renders a rename before the server has agreed, the request fails, and the reader is left looking at a value the server never accepted — on a form that believes it has no unsaved changes.
Context and Prerequisites
The lifecycle and the safety rules are in submission state and optimistic updates, including which operations may be rendered optimistically at all. This page is about the rollback: what has to be captured before the write, and what has to be restored after the failure.
Core Pattern: Capture, Apply, Restore
interface Snapshot<T> {
readonly values: Readonly<T>;
readonly dirty: Readonly<Record<string, boolean>>;
readonly errors: Readonly<Record<string, FieldError>>;
readonly disabled: readonly string[];
}
/**
* Capture BEFORE the optimistic write, and freeze it. A snapshot that shares
* structure with live state is rewritten by the reader's next keystroke, and
* the rollback then restores whatever they typed in the meantime.
*/
function capture<T extends object>(s: FormState<T>): Snapshot<T> {
return Object.freeze({
values: Object.freeze({ ...s.values }),
dirty: Object.freeze({ ...s.dirty }),
errors: Object.freeze({ ...s.errors }),
disabled: Object.freeze([...s.disabled]),
});
}
export async function optimistic<T extends object>(
s: FormState<T>, patch: Partial<T>, send: () => Promise<Response>,
): Promise<void> {
const before = capture(s);
s.apply(patch);
s.markSaved(); // the optimistic claim: this is now saved
try {
const res = await send();
if (res.ok) { s.confirm(await res.json()); return; }
// Rollback in the REVERSE order of application: values last, so the reader
// sees the field return to its old value after the controls come back.
s.enable(before.disabled);
s.setErrors(await mapServerErrors(res, patch));
s.setDirty(before.dirty); // still unsaved — this is the one that is missed
s.setValues(before.values);
s.announce('That change was not saved. Your edit is still here.', 'assertive');
} catch {
s.enable(before.disabled);
s.setDirty(before.dirty);
s.setValues(before.values);
s.announce('We could not reach the service. Try again.', 'assertive');
}
}
setDirty(before.dirty) is the line that decides whether the rollback is correct. Restoring values without restoring the dirty flags leaves a form that shows the old value and believes it is saved — so the save button is disabled, and the reader has no way to retry the change they just made.
Step-by-Step Walkthrough
-
Capture before applying. After the optimistic write the old values are gone.
-
Freeze the snapshot. Sharing structure with live state means the reader’s next keystroke rewrites your rollback target.
-
Restore in reverse order. Enable first, then errors, then dirty, then values — so the reader sees a form that works before they see the value move.
-
Restore the dirty flags. The edit is still unsaved; the form must agree.
-
Announce assertively. A value changing back on its own, with no announcement, is indistinguishable from the reader’s own typing being lost.
-
Do not roll back a 409. A conflict means the remote moved, not that the change was wrong — route it to reconciliation instead.
Failure Modes and Edge Cases
1. The reader edited during the request
The rollback would overwrite their newer edit with the pre-request value. Compare the current value against the optimistically applied one: if it already differs, restore everything except that field, and keep the error.
2. Two optimistic writes in flight
The second one’s snapshot contains the first one’s optimistic values, so rolling back the second restores an unconfirmed state. Either serialise the writes, or capture against the last confirmed state rather than the current one.
3. Rolling back a partial success
A batch where three of five items succeeded cannot be rolled back wholesale. Either make the endpoint atomic, or apply optimistically per item so each rolls back independently.
4. The snapshot outlives the form
A rollback firing after unmount writes into nothing, or throws. Abort the request in the teardown and check before restoring.
5. Announcing the wrong thing
“Something went wrong” for a 422 hides the reason. Map the server’s field errors first, then announce the count — the reader needs to know it was their input, not your service.
Verification Checklist
Common Pitfalls
- Sharing structure with live state. A snapshot that is not frozen is rewritten by the reader’s next keystroke, so the rollback restores whatever they typed while waiting.
- Restoring values but not dirty flags. The form shows the old value and believes it is saved, so the save control stays disabled and there is no way to retry the change.
- Rolling back a conflict. A 409 means the remote moved, not that the reader was wrong. Discarding their edit is exactly the wrong response.
- Rolling back a partial batch. Three of five items succeeded and the client cannot undo them. Apply optimistically per item, or make the endpoint atomic.
- Reverting silently. A value changing back on its own with no announcement is indistinguishable from the reader’s own typing being lost.
Related
- Submission State and Optimistic Updates — which operations may be optimistic
- Retrying Failed Submissions with Backoff — what happens after the rollback
- Server Error Reconciliation — routing the response that caused it
← Submission State and Optimistic Updates
Frequently Asked Questions
What if the reader edits the field while the request is in flight?
Compare the field’s current value against the value you optimistically applied. If they match, the reader has not touched it and the rollback is safe. If they differ, the reader has moved on and restoring would destroy their newer edit — so restore everything except that field, keep the server’s error attached to it, and let them decide. This is the same staleness comparison used for server errors generally.
Should a 409 conflict trigger a rollback?
No. A conflict means the remote record moved on, not that the reader’s change was wrong, so discarding their edit is exactly the wrong response. Route it to reconciliation, where the reader can see both versions and choose. Rolling back a conflict is how a legitimate edit disappears because someone else touched a different field of the same record.
How do I roll back a batch where some items succeeded?
You mostly cannot, which is an argument for not applying a batch optimistically at all. If the endpoint can be made atomic, do that. If not, apply each item optimistically and independently so each one has its own snapshot and its own rollback — the reader then sees three rows confirmed and two reverted, which is the truth, rather than five rows reverted or five left wrong.