When a save succeeds the form should stop reporting unsaved changes, but the naive fix — copy the current values into the baseline — silently marks as saved anything the user typed while the request was in flight, and they lose it on the next navigation.

Dirty and pristine state tracking defines dirtiness as “current differs from baseline”. That definition only stays honest if the baseline always equals what the server has. So the rebase must be driven by what was sent and what came back, not by what happens to be in the inputs when the response lands.


Context and prerequisites

A save has three moments that matter: the moment you snapshot the values to send, the moment the response arrives, and every edit in between. With autosave or a slow network, “in between” is often seconds long, and users keep typing. There are three plausible rebase targets:

  1. The current values at response time. Wrong: includes unsent edits.
  2. The snapshot you sent. Close: correct unless the server normalised or rejected part of it.
  3. The server’s echo of the saved record. Best: it is literally what the server now holds — trimmed strings, generated ids, rounded numbers and all.

Rebase to (3) when the API returns the saved entity, fall back to (2) when it returns only a status, and never use (1). Then recompute dirtiness against the new baseline, which leaves in-flight edits correctly flagged as dirty.

Edits made while a save is in flight The snapshot is taken and the request sent at time zero. At 400 milliseconds, while the request is still in flight, the user edits the bio field. The response arrives at 900 milliseconds. Rebasing to current values marks the bio edit as saved even though it was never sent. Rebasing to the snapshot or the server echo leaves the bio edit dirty, so the unsaved-changes guard still protects it. Request PUT /profile (snapshot v1) User edits bio Rebase to current bio edit lost Rebase to echo bio still dirty 0ms 300ms 600ms 900ms 1200ms

The core pattern: snapshot, send, rebase from the echo

type Values = Record<string, unknown>;

export interface SaveResult<T extends Values> {
  saved: T | null;     // the server's echo of the stored record, when the API returns one
}

export class FormBaseline<T extends Values> {
  private baseline: T;
  private inflight = 0;               // guards against out-of-order responses

  constructor(initial: T, private equals: (a: unknown, b: unknown) => boolean) {
    this.baseline = structuredClone(initial);
  }

  dirtyKeys(current: T): (keyof T)[] {
    return (Object.keys(current) as (keyof T)[]).filter((k) => !this.equals(current[k], this.baseline[k]));
  }

  async save(current: T, send: (payload: T) => Promise<SaveResult<T>>): Promise<(keyof T)[]> {
    // Snapshot BEFORE the await. structuredClone detaches it from later edits,
    // so mutations to `current` during the request cannot leak into it.
    const snapshot = structuredClone(current);
    const ticket = ++this.inflight;
    const result = await send(snapshot);

    // An older save resolving after a newer one must not roll the baseline back.
    if (ticket !== this.inflight) return this.dirtyKeys(current);

    // Prefer the server's echo: it reflects normalisation (trimmed strings,
    // rounded numbers, generated ids) that the snapshot does not.
    this.baseline = structuredClone(result.saved ?? snapshot);
    return this.dirtyKeys(current);   // edits made in flight remain dirty
  }

  /** Server echo may differ from what the user sees; offer to adopt it. */
  normalisedFields(current: T): (keyof T)[] {
    return this.dirtyKeys(current);
  }
}

The return value is the list of fields still dirty after the rebase. Usually it is empty. When it is not, those fields were edited during the save — or the server changed them — and they stay flagged, so a navigation guard and the save button still reflect reality.


Step-by-step walkthrough

  1. Snapshot with structuredClone before awaiting. A shallow copy shares nested objects with live state, so an edit to an address line during the request would change the “sent” snapshot too.
  2. Number every save. Keep a monotonically increasing ticket and ignore any response whose ticket is not the latest. This is the same stale-response rule used for cancelling stale async validation, applied to saves.
  3. Rebase to the echo, else the snapshot. Never to the live values.
  4. Recompute dirty keys against the live values. Anything typed in flight is still different from the new baseline, so it stays dirty and will be picked up by the next save.
  5. Decide what to do about server normalisation. If the echo trimmed "Ada " to “Ada”, the live input still shows the space and is now dirty. Either write the echoed value back into untouched fields (safe — the user did not edit them in flight) or accept the dirty flag until the next save.
Out-of-order save responses The form sends save one with ticket one, then the user edits and the form sends save two with ticket two. The server answers save two first, and the baseline rebases to its echo. Then the server answers save one. Because ticket one is no longer the latest, the baseline ignores it. Without the ticket check the late response would roll the baseline back to older values and mark saved fields as dirty again. Form Baseline Server save #1 (ticket 1) user edits, save #2 (ticket 2) #2 echo arrives first rebase to #2, form pristine #1 echo arrives late ticket 1 is stale: ignored Autosave makes this ordering common rather than exotic: a debounced save can easily overlap the previous one on a slow connection.

Failure modes and edge cases

1. Resetting the whole form after save

form.reset() or a library reset() with no arguments restores the initial values from first mount — it does not adopt the saved ones. After a successful save, call reset(savedValues) or rebase as above; otherwise the next reset takes the user back to pre-save data.

2. The server normalises a field the user is still editing

If the echo differs from the live value and the field was edited in flight, do not overwrite it: the user’s newest text wins, and the field stays dirty. Only write echoed values into fields whose live value still equals the snapshot.

for (const k of Object.keys(echo) as (keyof T)[]) {
  if (equals(current[k], snapshot[k]) && !equals(echo[k], current[k])) setField(k, echo[k]);
}

3. Partial saves

A PATCH that sends only dirty fields must rebase only those fields. Merging the echo into the baseline field by field (baseline = { ...baseline, ...pick(echo, sentKeys) }) keeps unsent fields’ baselines intact.

4. Validation failure is not success

A 422 must not rebase anything. Keep the baseline, keep the form dirty, and route the errors as described in mapping 422 responses to field errors.

5. Draft storage still holds the pre-save draft

If you persist drafts, clear or overwrite the stored draft in the same step as the rebase. A leftover draft offered on the next visit is a stale copy of data that is already saved, as covered in autosaving form drafts to localStorage.

What to rebase to, by API response shape When the API returns the full saved entity, rebase to that echo and write server-normalised values into fields the user did not touch in flight. When it returns only a status such as 204, rebase to the snapshot that was sent. When it returns a partial echo for a PATCH, merge the echoed fields into the existing baseline. When it returns a 422 validation error, do not rebase at all. Response Rebase to Adopt server values? 200 with full entity the echo yes, for fields untouched in flight 204 No Content the sent snapshot nothing to adopt 200 with partial echo (PATCH) baseline merged with echoed keys yes, for echoed keys only 422 / 409 do not rebase no; keep the form dirty

Verification checklist


Frequently Asked Questions

Why not just disable the form while saving so nothing changes in flight?

Disabling inputs during every save makes autosave unusable and, for explicit saves, throws away focus and interrupts typing for the length of a network round trip. It also removes the fields from the accessibility tree’s operable set. Snapshotting is cheaper and keeps the form responsive.

Is structuredClone safe for form values?

Yes for plain data — strings, numbers, dates, arrays, objects, File and Blob. It throws on functions and DOM nodes, which should not be in form values anyway. If your state holds class instances with methods, serialise to plain objects before snapshotting.

How do I test the in-flight edit case?

Control the response timing: with a mocked server that resolves on demand, trigger the save, type into a field, then resolve. Assert that field is still dirty and that the others are pristine. Mocking async validators with MSW shows the deferred-response technique.


Related

← Dirty and Pristine State Tracking