StateGENERALFRAMEWORK-SPECIFICBROWSER-SPECIFIC

Form State Is a Draft

A form is a staging area for a mutation that has not happened yet: the user is authoritative until submit, dirtiness is the tracked fact, and the record is only replaced when the server agrees.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

While someone is filling in a form, who owns those values — and what happens to them if they leave?

The user intent

A person is changing something: their address, a price, a description. They want to try wording, change their mind, and be sure that what they see is what will be saved — and that nothing else will overwrite it while they think.

The obvious build

Bind the inputs straight to the record. Every keystroke updates the object the rest of the page reads, so the preview stays in sync and there is only one copy of the data.

Why it breaks

A background revalidation for the record lands mid-edit and replaces the object the inputs are bound to. Three sentences of typing disappear and the user cannot tell why (State Synchronization).

How it breaks in a real browser
  • A background revalidation for the record lands mid-edit and replaces the object the inputs are bound to. Three sentences of typing disappear and the user cannot tell why (State Synchronization).
  • Every keystroke mutates shared state, so the table row behind the modal updates live — showing an unsaved value as though it were saved, to this user and to nobody else.
  • There is no way to cancel, because there is nothing to cancel back to: the original value was overwritten by the first keystroke.
  • There is no way to know whether anything changed, so the unsaved-changes warning either never fires or fires on every close, and users learn to dismiss it without reading.
  • Validation has no moment to run against. Errors either appear on the first character of an empty field — before the user has done anything wrong — or never appear until the server rejects the whole submission (Errors People Can Actually Perceive).
  • A double-click on Submit sends two mutations, and without an idempotency key the server creates two records (Idempotency Keys: The Mechanism).
  • On failure the fields are reset from the record, so the user's work is destroyed by the error path, which is the moment they least want to retype it.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A form is a staging area: a copy of the record that the user is authoritative for, which becomes real only when a mutation succeeds. Until then it is expected — and allowed — to disagree with the server (The Seven Kinds of State).
  • Three values are in play, and conflating any two of them is the source of most form bugs: the initial value the form opened with, the current draft, and the server's value right now, which may have moved since the form opened.
  • Dirty is current !== initial, computed rather than tracked with a flag. Touched is whether the user has visited a field, and it is what decides *when* to show an error, not whether one exists (Native Validation and Its Limits).
  • Submission is a state machine, not a boolean: idle → validating → submitting → succeeded or failed, with distinct UI at each step and no path that leaves the button spinning forever (Submission: Method, Encoding and Doing It Once).
  • The browser already supplies part of this. A native form gives Enter-to-submit, implicit submission semantics, constraint validation, and a beforeunload hook for warning about unsaved work — all of it before any framework state exists (Native Forms First).
  • The unsaved-changes warning has two mechanisms with different reach. beforeunload covers reloads, tab closes and real navigations, and browsers show their own non-customisable message; client-side route changes are not navigations at all and must be blocked by the router (Client-Side Routing).
  • On success, the correct move is to invalidate the record's cache key and let the view re-derive — not to write the draft into the cache as though it were the server's answer, because the server may have normalised, defaulted or rejected parts of it (Query Keys and Invalidation).

What this makes the browser do

And which of it is avoidable.

  • Keystroke-frequency state updates are the highest-frequency renders in most applications. If the whole form re-renders per character, cost scales with field count and with whatever is rendered alongside (What a Component Costs to Render).
  • Uncontrolled inputs let the browser own the value and cost nothing per keystroke; controlled inputs re-render on every character, which is what buys derived previews and live validation (Controlled vs Uncontrolled Inputs).
  • Validation on every keystroke over a large schema is main-thread work at typing frequency — the standard cause of a caret that lags behind the typing (Interaction Responsiveness).
  • A dirty check that deep-compares a large object on every keystroke costs more than the render it is attached to; compare per field, or compare on blur.
  • A beforeunload listener registered unconditionally disqualifies the page from the browser's back/forward cache, which makes every back navigation a full load instead of a restore (History and Navigation).

Bound to the record, or bound to a draft

The distinction looks cosmetic and decides four separate behaviours: whether a background update can destroy the user's typing, whether Cancel is possible, whether "has anything changed" is answerable, and whether the rest of the page shows unsaved values as though they were saved.

Note that the second version never writes the draft into the shared record. It sends the draft, waits, and then invalidates — so the value everything else renders always came from the server, and the value in the inputs always came from the user.

Two bindings for one edit form
Bound to the record
<input value={order.notes}
       onInput={e => order.notes = e.target.value} />

// - a refetch replaces `order` mid-keystroke
// - the table row behind the modal shows unsaved text
// - Cancel has nothing to restore
// - "is it dirty?" is unanswerable
// - on failure, fields reset from the record
Bound to a draft
const initial = useMemo(() => snapshot(order), [order.id])  // once, per record
const [draft, setDraft] = useState(initial)
const dirty = !equalFields(draft, initial)

<input value={draft.notes}
       onInput={e => setDraft({ ...draft, notes: e.target.value })} />

onSubmit: save(draft, { version: initial.version, idempotencyKey })
  .then(() => invalidate(['order', order.id]))   // re-derive from the server
  .catch(showFieldErrors)                        // draft is untouched

Keying the snapshot to the record id, rather than to the record object, is what stops a background revalidation from re-initialising the draft: the object identity changes on every refetch, the id does not. Sending initial.version turns a silent overwrite of someone else's edit into a conflict the user can see, and invalidating rather than writing the draft into the cache means server-side normalisation is visible instead of hidden.

The states a form is actually in

A form has more states than "editing" and "submitting", and each one has a different correct answer for what the submit button says, whether background updates may apply, and what happens if the user tries to leave. Most form bugs are a state in this table that was never given a behaviour.

The last column is the one that gets skipped. "What happens if they leave now" has to be answered for every row, and for three of them the answer is a prompt the user must be able to dismiss with a keyboard.

StateHow you knowSubmit controlMay a refetch apply?If the user leaves
Pristinedraft equals initialEnabled or hidden; nothing to saveYes — safely re-initialiseLeave silently
Dirtydraft differs from initialEnabledNo — hold the updateWarn, and offer to save
Invalid and toucheda rule fails on a visited fieldEnabled, so submit can announce the errorsNoWarn
Submittinga mutation is in flightDisabled, with progress announcedNoWarn; the request may still land
Failedthe server rejected itEnabled for retry, same idempotency keyNo — the draft is still the user'sWarn
Conflictedthe version check failedBlocked until the user choosesShow theirs and yours side by sideWarn strongly — a choice is pending
Succeededthe server confirmedReset or navigate awayYes — invalidate and re-deriveLeave silently

What the form owes a keyboard and a screen reader

A form that works only with a mouse and only for someone who can see red text is not finished. The specification below is the minimum, and the unsaved-changes dialog is included because it is the part teams build last and least carefully.

accessibility specForm with per-field validation and a leave-confirmation dialogEdit form with unsaved-changes protection

semantics A native form with a real submit button; each control labelled by a label with a for; invalid controls carry aria-invalid="true" and aria-describedby pointing at their message; the submit-failure summary is a focusable region; the leave dialog is role="dialog" with aria-modal="true" and a name that states the risk.

Tab / Shift+TabMoves through controls in DOM order; error messages sit next to their field so they are encountered in context
EnterSubmits from any single-line input — a native behaviour that disappears if the submit control is not a real button
EscapeDismisses the leave-confirmation dialog and returns to the form, treated as "stay"
Tab within the dialogCycles inside it and cannot reach the form behind, which is inert while it is open
Focus
  • On submit failure, move focus to the error summary or to the first invalid control — never leave it on the submit button.
  • Opening the leave dialog moves focus to it; dismissing it returns focus to the element that triggered the navigation.
  • Server-side field errors move focus the same way client-side ones do; the user cannot tell which kind they got and should not have to.
  • Never move focus on keystroke-driven validation — retargeting focus while someone is typing makes the form unusable.
Announces
  • On submit failure: an assertive announcement naming how many fields need attention, followed by the summary list.
  • Per field, on blur: the error text, associated by aria-describedby so it is read when the field is next reached.
  • Autosave: a polite "Draft saved" — polite specifically so it queues behind typing rather than interrupting it.
  • On success: a polite confirmation, or a heading change if the view navigates (Live Regions and Announcement).

usually broken by The invited mistake is announcing errors on every keystroke. An assertive live region wired to a validation result interrupts the screen reader mid-word on every character, which is far worse than no announcement at all — validate on blur and on submit, and keep the running commentary out of the live region.

How to build it

Most important first.

  • Edit a copy. Initialise the draft from the record once, and never let a background update write into it while the form is dirty.
  • Compute dirtiness from initial versus current rather than setting a flag, so it cannot be left stale — and so that typing a change and typing it back reports "not dirty", which is what the user believes (Derived State).
  • Validate on submit, and additionally on blur for fields the user has touched. Live validation of an untouched empty required field tells someone they are wrong before they have started (Errors People Can Actually Perceive).
  • Model submission as a state machine and disable the submit control while a submission is in flight; send an idempotency key so a retry or a double-click cannot create two records (Idempotency Keys: The Mechanism).
  • Never clear the fields on failure. Keep the draft, show what failed and where, and let the user retry from where they were (Loading, Error, Empty — The States You Did Not Render).
  • On success, invalidate rather than assume: re-derive the record from the server's response so normalisation and server-set fields are visible (State Synchronization).
  • Warn about unsaved work in both directions — beforeunload for real navigations, a router block for client-side ones — and register the listener only while the form is actually dirty.
  • For long forms, autosave the draft to storage with a version tag and a clear indicator, and treat the restored draft as a suggestion the user can discard rather than as the record (Persistent Client State).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Every input needs a programmatically associated label. Placeholder text is not a label: it disappears on focus, it is not reliably exposed to assistive technology, and it fails at low vision and at translation (Semantics Before ARIA).
  • Errors must be announced, not merely coloured. Associate the message with the field via aria-describedby, mark the field aria-invalid, and put a summary in a live region on submit failure (Errors People Can Actually Perceive, Live Regions and Announcement).
  • On a failed submit, move focus to the first invalid field or to the error summary. Otherwise focus stays on a submit button and a screen-reader user is told nothing happened (Focus Management).
  • Autosave and "saving…" indicators are visual by default. A polite live region announcing "Draft saved" is what makes the state perceivable to a non-visual user — and it must be polite, so it does not interrupt typing (Live Regions and Announcement).
  • The unsaved-changes dialog is a focus trap by nature: focus must move into it, cycle within it, and return to where it came from on dismissal, with Escape cancelling (Focus Management).
  • Keep the form a real form element with a real submit button so Enter submits. Reimplementing that with a click handler on a div removes a keyboard behaviour users rely on without ever being taught it (Native Forms First).

What can go wrong

Failure modes
  • A background refetch overwriting the draft. The single most damaging form bug, and it is invisible in development because nothing revalidates while you are looking at it.
  • The mitigation failing: a dirty flag that is set but never cleared after a successful save, so the user is warned about unsaved changes they have already saved and learns to ignore the warning.
  • A submit handler that is not guarded against re-entry, producing duplicate records on a double-click or an impatient retry (Retries, and the Duplicate Order).
  • Server-side validation errors returned per field but rendered as one banner, leaving the user to find which of thirty fields is wrong (Validation Errors: Feedback, Not Verdicts).
  • Autosaved drafts that outlive the schema, restoring last month's field names into this month's form.
  • An unsaved-changes warning that fires on a form the user only opened, because the draft was initialised with normalised values that differ from the record by whitespace or number formatting.
  • A form whose validation passes locally and fails server-side every time, because the client re-implemented a rule the server actually owns (Business Validation).
What can arrive out of order
Security
  • Client-side validation is a usability feature. Every rule must be enforced again on the server, which is the only place that is not under the user's control (What the Frontend Is Responsible For in Auth).
  • Never treat the set of fields the form sends as the set of fields the server should accept. A user can add fields to the request; the server must accept only what it means to (Mass Assignment and Over-Posting).
  • Drafts autosaved to storage are same-origin readable by any script on the page and survive logout unless you clear them. A half-typed message or a payment detail sitting in localStorage is a real exposure (Storage Security and Durability).
  • Forms that mutate state must be protected against cross-site submission by the server; the browser will happily attach cookies to a request your page did not initiate (Cross-Site Request Forgery).
  • Autofill puts values into fields your code did not write. Do not assume the draft is empty because the user has not typed (Input Types, Inputmode and Autocomplete).
Misreads
  • "Two-way binding means I do not need a draft." Two-way binding is *how* you edit the draft. The question is what it is bound *to* — a copy, or the record everything else reads.
  • "Dirty tracking needs a flag." It needs a comparison. A flag is a second owner of a derivable fact, and it will be left set after a save (Derived State).
  • "Validate as they type so they know sooner." Validate as they *finish* a field. Telling someone their empty email is invalid before they have typed anything is noise, and noise trains people to ignore errors.
  • "The server accepted it, so write the draft into the cache." The server may have normalised, trimmed, defaulted or partially applied it. Re-derive from the response (State Synchronization).
  • "beforeunload covers navigation." It covers reloads, closes and cross-document navigations. A client-side route change is not one, and needs a router-level block (Client-Side Routing).

Measuring it, and what changes in the field

How you would see this
  • Type quickly in a long form with CPU throttling on. If the caret lags, per-keystroke work — validation, dirty checking, or a whole-form re-render — is the cause (Interaction Responsiveness).
  • Trigger a background revalidation while a field is focused and typing. Whether the draft survives is a one-minute test of the most damaging bug in this lesson.
  • Field analytics on abandonment: which field the user was on when they left is usually a validation or labelling problem rather than a motivation one (Analytics Events That Answer a Question).
  • Error tracking on submit failures, split by client-side rejection versus server-side rejection; a high server-side rate means the client and server rules disagree (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a slow network, submission takes long enough that the user will press the button again. The in-flight state and the idempotency key are what make that harmless (Idempotency Keys: The Mechanism).
  • On a slow device, per-keystroke validation over a large schema is the thing that makes typing feel broken.
  • On mobile, a tab can be discarded while the user switches apps to look something up — an autosaved draft is the difference between finishing and starting over (The Multi-Process Browser).
  • For a long form, session expiry mid-edit is likely; the submit must reauthenticate and return the user to their draft rather than to an empty form (Session Expiry and the Refresh Race).
What this costs
  • A draft copy is deliberate duplication. It is the exception to Derived State, and it is justified by the fact that the two values are *meant* to differ — but it does mean writing down when and how they reconcile.
  • Suspending background updates while dirty means the user may be editing a record someone else has changed. That is the right default and it makes a conflict on submit more likely, which is why the version check matters (Optimistic Concurrency: Versions and If-Match).
  • Controlled inputs cost a render per keystroke and buy live derivation and validation; uncontrolled inputs are cheaper and make previews and cross-field rules harder (Controlled vs Uncontrolled Inputs).
  • Autosaving drafts to storage adds versioning, migration, quota and an exposure surface, in exchange for never losing work.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe draft model, dirtiness as a comparison, and the submission state machine are consequences of a mutation being asynchronous and a user being authoritative until it happens. They hold in every framework and in a plain HTML form posted to a server.
  • FRAMEWORK-SPECIFICThe libraries disagree about who holds the draft. React form libraries typically keep it uncontrolled in the DOM and subscribe per field to avoid a whole-form render per keystroke, whereas plain useState binding re-renders everything; Vue's v-model and Svelte's bind: write into a reactive object with per-binding updates; Angular ships two complete systems — template-driven and reactive forms — and its FormControl already tracks pristine, dirty, touched and status for you, so "implement dirty tracking" is advice that does not apply there. Solid's fine-grained signals mean a per-keystroke update touches only the bindings that read it.
  • BROWSER-SPECIFICbeforeunload behaviour differs: browsers no longer show a custom message and several require a prior user interaction with the page before the prompt is honoured at all, so the warning cannot be relied on as the only protection for the user's work. Autosave is the durable answer; the prompt is the courtesy.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — a draft is the command object of a mutation, held by the UI until it is dispatched; naming it that way makes the reconciliation rules obvious.