Lesson 17 / 22
Form State Management
Whether a field's value lives in the document or in the application's state, the notification cost of the two approaches, the information form state carries besides values, and the role of the initial value and dirtiness in the submission flow.
Contents
The Data Access topic built its flow in one direction: from the server to the screen. The second half of the North Slope interface runs in the opposite direction. The observer opens the measurement entry form, selects the station, types the value, adds a note, and submits it. The first question in this flow is technically small but has large consequences: where does a field’s current value live?
There are two answers. The value can live in the document itself — the field holds what was typed, and the application reads it when needed. Or the value can live in the application’s state — the field only displays the state, every keystroke updates the state, and the field re-renders from the updated state. This lesson covers what each answer gains and costs, and what form state carries besides values.
Two Sources of Truth
Uncontrolled input holds the value in the document. The field manages its own state; the application supplies the initial value and does not intervene afterward. The value is read at submission time or whenever needed. The entry list defined in the Web Fundamentals and HTML course is this approach’s natural counterpart: the browser already collects the fields on its own.
Controlled input holds the value in the application’s state. The field takes its displayed value from the state and fires an event that updates the state on every change. The application always knows the value the user sees, and can change it.
The source of the distinction is the attribute–property distinction from the Browser and the Web Platform course: a field’s initial value is the attribute declared in the markup, while its current value is the element’s property. An uncontrolled field lets the two live apart; a controlled field equalizes them after every change.
What does being controlled make possible? Transforming the value while it is typed — uppercasing it, applying a format mask, dropping a disallowed character. Dependency between fields — narrowing the unit list when the station changes. Instant validation and interface decisions tied to the value. And persisting an unsaved draft: the value is already in the application’s hands.
What does it make expensive? Every keystroke produces a state update and a re-render. In a form with thirty fields, this cost becomes measurable.
Notification Cost
The size of the cost depends on subscription granularity: who gets notified when the state changes? The model below counts two extreme approaches — fields subscribed to the whole form state, and fields subscribed only to their own value.
// subscription-granularity.mjs — notification cost at form level and field level const FIELDS = ["station", "date", "value", "unit", "note", "observer", "device", "wind", "humidity", "pressure", "snow", "description"]; function store(granularity) { // granularity: "form" | "field" const subscribers = new Map(); // path -> list of field renderers const renders = new Map(); // field -> call count return { subscribe(path, field) { const key = granularity === "form" ? "*" : path; if (!subscribers.has(key)) subscribers.set(key, []); subscribers.get(key).push(field); renders.set(field, 0); }, write(path) { const key = granularity === "form" ? "*" : path; for (const field of subscribers.get(key) ?? []) { renders.set(field, renders.get(field) + 1); } }, total: () => [...renders.values()].reduce((t, s) => t + s, 0), }; } // The user's keystroke sequence: 30 changes across three fields. const KEYSTROKES = [ ...Array(14).fill("description"), ...Array(10).fill("value"), ...Array(6).fill("note"), ]; const LABELS = { form: "form-level", field: "field-level" }; for (const granularity of ["form", "field"]) { const s = store(granularity); for (const f of FIELDS) s.subscribe(f, f); for (const path of KEYSTROKES) s.write(path); console.log( `${LABELS[granularity].padEnd(12)}:`, `${KEYSTROKES.length} changes →`, `${String(s.total()).padStart(4)} field renders`); } // Uncontrolled field: value stays in the document, read once at submission. const s = store("field"); for (const f of FIELDS) s.subscribe(f, f); console.log(`${"uncontrolled".padEnd(12)}:`, `${KEYSTROKES.length} changes →`, `${String(s.total()).padStart(4)} field renders`);
form-level : 30 changes → 360 field renders field-level : 30 changes → 30 field renders uncontrolled: 30 changes → 0 field renders
The numbers show the ratio: at form granularity, every change renders twelve fields; at field granularity, only the one that changed. The twelvefold difference grows in direct proportion to the number of fields.
The rule that follows is not “a controlled field is expensive,” but “building a controlled field at form granularity is expensive.” Even when the value lives in the application’s state, if the subscription is built at field granularity, the cost stays limited to a single field. In most cases this is achieved by offering a separate read path per field, rather than sharing the form state as a single object.
The third row is a limiting case: when the value stays in the document, no rendering happens at all. Its cost is that the application does not know what the user typed until submission. A mixed arrangement is also possible and common: most fields uncontrolled, with only the fields that are interdependent or transformed while typed left controlled.
Form State Is Not Just Values
Even after the decision about where values live is made, the form still carries a few more pieces of information the screen needs. When an error message appears, when the submit button is disabled, and whether a warning appears when leaving the page all depend on this information.
// form-state.mjs — the state reducer for the measurement entry form const INITIAL = { station: "NS-01", date: "2026-01-14", value: "", unit: "C", note: "", }; const newState = () => ({ values: { ...INITIAL }, touched: new Set(), submitting: false, submitCount: 0, }); const dirtyFields = (s) => Object.keys(INITIAL).filter((f) => s.values[f] !== INITIAL[f]); function reduce(s, [event, field, value]) { switch (event) { case "changed": return { ...s, values: { ...s.values, [field]: value } }; case "touched": return { ...s, touched: new Set([...s.touched, field]) }; case "submitStarted": return { ...s, submitting: true, submitCount: s.submitCount + 1, touched: new Set(Object.keys(INITIAL)) }; case "submitEnded": return { ...s, submitting: false }; case "accepted": // server accepted the record: new baseline return { ...newState(), submitCount: s.submitCount }; case "reset": return { ...newState(), submitCount: s.submitCount }; default: throw new Error(`unknown event: ${event}`); } } const EVENTS = [ ["changed", "value", "-"], ["changed", "value", "-4"], ["changed", "value", "-4.2"], ["touched", "value"], ["changed", "note", "morning reading"], ["submitStarted"], ["submitEnded"], ["accepted"], ]; let s = newState(); const log = (label) => console.log( label.padEnd(30), `value=${JSON.stringify(s.values.value).padEnd(7)}`, `dirty=[${dirtyFields(s).join(",")}]`.padEnd(20), `touched=${s.touched.size}`.padEnd(11), `submitting=${s.submitting}`.padEnd(17), `submitCount=${s.submitCount}`); log("initial"); for (const event of EVENTS) { s = reduce(s, event); log(event.filter(Boolean).join(" ")); } // --- The leave-warning decision --------------------------------------------- console.log(""); for (const scenario of [ ["empty form", newState()], ["partially filled", reduce(newState(), ["changed", "value", "-4.2"])], ["after submit", s], ]) { const [name, scenarioState] = scenario; const dirty = dirtyFields(scenarioState); console.log(`${name.padEnd(20)} dirty=${dirty.length} → leave warning: ` + (dirty.length > 0 ? "shown" : "not shown")); }
initial value="" dirty=[] touched=0 submitting=false submitCount=0 changed value - value="-" dirty=[value] touched=0 submitting=false submitCount=0 changed value -4 value="-4" dirty=[value] touched=0 submitting=false submitCount=0 changed value -4.2 value="-4.2" dirty=[value] touched=0 submitting=false submitCount=0 touched value value="-4.2" dirty=[value] touched=1 submitting=false submitCount=0 changed note morning reading value="-4.2" dirty=[value,note] touched=1 submitting=false submitCount=0 submitStarted value="-4.2" dirty=[value,note] touched=5 submitting=true submitCount=1 submitEnded value="-4.2" dirty=[value,note] touched=5 submitting=false submitCount=1 accepted value="" dirty=[] touched=0 submitting=false submitCount=1 empty form dirty=0 → leave warning: not shown partially filled dirty=1 → leave warning: shown after submit dirty=0 → leave warning: not shown
Four separate pieces of information appear, and each answers a different question.
Value is the field’s content. Touched is whether the user has left that field; the rule for when to show an error in the Form Events lesson rests on this information — in the first three rows the value changed but the touched count stayed at zero, meaning the user is still typing and showing an error would be premature.
Dirty means the value differs from the initial one. It is not kept as a separate flag; it is computed by comparing against the initial values. If it were kept as a flag, the form would stay incorrectly dirty after the user changed a field and then changed it back to its original value.
Submitting and submit count are separate pieces of information. The first keeps the button disabled; the second permanently marks that the user has attempted a submission once, and from then on lets errors show without waiting for touched. The sixth row shows all fields counted as touched the moment submission starts — this single line establishes the “show every error the moment submit is pressed” behavior.
Initial Values, Dirtiness, and Reset
Initial values are part of the form state, not a constant baked into the code. There are two reasons for this.
The first is that dirtiness must be computable. Without a comparison baseline, dirtiness cannot be known. The output’s last block shows where this computation connects: a user trying to leave a form with a dirty field is shown a warning; a clean form is not. Showing the warning unconditionally — even when the user has typed nothing — makes the interface annoying, and it gets ignored over time.
The second is that initial values can change. A form editing an existing measurement gets its initial values from the server. When the record loads, the form state must be built against the new baseline; otherwise the loaded values look dirty, and an “unsaved change” warning appears on an untouched form.
Three separate operations are frequently confused. Reset returns the values to their
initial state. Clear empties the values — that is not the same as returning to the
initial state; in an edit form the two produce very different results. Rebaselining takes
the current values as the new initial state; this is what happens once the server accepts the
record. In the output, the accepted event does this: the form was cleared and dirtiness
reset, but the submit count was preserved.
The Moment of Submission
Submission is the moment when form state is most fragile, and it requires three rules.
Double submission is prevented. No new submission starts while the submitting flag is true. Visually disabling the button is not enough; a keyboard-triggered submission or a fast double click can arrive before the button’s state does, so the block is built into the state. This is the form-side counterpart of the idempotency key from the Retry and Backoff lesson — the block is on the client, the guarantee is on the server.
Field errors from the server are attached to the form. In the REST Client lesson, the
body of a 422 response carried its errors in a detail field; the field names in that body
match the form’s fields, and errors are placed next to the matching field. If a field name
does not match, the error is shown at the form level instead of being swallowed.
Values are preserved on a failed submission. Emptying a form the user filled out just because the server rejected it makes them start the work over. The values stay in place; only the errors are added.
The perceived duration of a submission, from the user’s side, is a separate matter. Assuming the record will be accepted by the server and updating the screen right away — rolling back if an error arrives — is the form-side application of the optimistic update from the State Management topic. Storing an unfinished form as a draft rests on the storage decision from the Persistent State lesson; which initial values the stored draft is compared against is determined by the baseline rule above.
Summary
- A field’s value is held either in the document (uncontrolled) or in the application’s state (controlled); the distinction comes from whether the attribute and the property live apart.
- Being controlled makes transformation while typing, dependency between fields, and instant validation possible; its cost is the notification produced on every change.
- What determines notification cost is not being controlled but subscription granularity; field-level subscription cuts the render count to a twelfth in a twelve-field form.
- Form state carries touched, dirty, a submitting flag, and a submit count alongside values; each feeds a different interface decision.
- Dirtiness is not kept as a flag; it is computed against the initial values. Reset, clear, and rebaselining are three separate operations.
- At submission, double submission is blocked at the state level, field errors from the server are attached to the matching fields, and the user’s entered values are preserved on failure.
Next Step
This lesson held the form’s values but never looked at whether those values are acceptable. The measurement value must be a number, must fall within a reasonable range for the temperature, and the date must not be in the future. These rules need to be known in two places: on the client, to give the user instant feedback, and on the server, knowing that a request can be built without the page ever being used. Writing the same rule separately in both places means the two will drift apart over time. The next lesson gathers the rules into a single declaration and shows that the same declaration produces the same result on both sides.
To keep your progress and take notes, Log in
My notes
Log in to take notes.