---
title: 'Form State Management'
source: 'https://academia.sh/en/courses/frontend-architecture/form-state-management'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```js
// 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.

```js
// 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.
