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.

Four things to capture, four distinct failures if you do not Values: not restoring them leaves the reader looking at a change the server refused, which they will assume succeeded. Dirty flags: not restoring them leaves the form claiming to be saved while holding unsaved work, so the save control stays disabled and there is no way to retry. Errors: not restoring them leaves messages from before the attempt mixed with messages the server has just returned, so the reader cannot tell which are current. Disabled controls: not restoring them leaves the form permanently locked, because the enable step is usually written only on the success path. Captured If it is not restored What the reader sees values a refused change stays on screen it looks like it saved dirty flags the form claims to be saved save is disabled; no retry errors old and new messages mix cannot tell which is current disabled controls the enable only ran on success the form is locked The second row is the one that reaches production: the values look right, so the bug is only found when someone tries to retry. Capture, apply, and the two ways it ends A frozen snapshot is taken before anything changes. The patch is applied and the form claims to be saved. On success the snapshot is discarded along with the idempotency key, because there is nothing left to undo. On failure everything in the snapshot is restored in reverse order and the failure is announced — and the key is kept, because a corrected retry is still the same logical request. Capture, apply, and the two ways it ends capture frozen snapshot, before anything moves apply the patch lands, the form claims saved success discard the snapshot and the key failure restore in reverse, keep the key Freezing is not ceremony: an unfrozen snapshot is rewritten by the reader’s next keystroke, and the rollback restores that.

Step-by-Step Walkthrough

  1. Capture before applying. After the optimistic write the old values are gone.

  2. Freeze the snapshot. Sharing structure with live state means the reader’s next keystroke rewrites your rollback target.

  3. 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.

  4. Restore the dirty flags. The edit is still unsaved; the form must agree.

  5. Announce assertively. A value changing back on its own, with no announcement, is indistinguishable from the reader’s own typing being lost.

  6. 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.

The rollback order, and what each step depends on Controls are re-enabled first, so the form is usable before anything about it changes. Errors are attached next, so the reason is on screen before the value moves. Dirty flags are restored third, so the form knows it holds unsaved work before that work reappears. Values are restored last, which is the step the reader actually sees. Doing it in the reverse order — values first — flashes the old value onto a form that is still disabled and still claiming to be saved. Order Restore So that 1 enabled state the form is usable before anything changes 2 errors the reason is visible before the value moves 3 dirty flags the form knows it holds unsaved work 4 values the visible change comes last Reverse this and the reader sees the old value flash back onto a form that is still disabled and still says "saved".

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

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.