The exact problem: a form mirrors every input into state so it can build a payload at submit — sixty subscriptions, sixty re-renders per keystroke — when the browser has been holding those values the whole time and will hand them over in one call.

Context and Prerequisites

This is the read side of the uncontrolled pattern from controlled vs uncontrolled forms. It assumes the fields are uncontrolled, or at least that the DOM is the source of truth at submit time; best practices for uncontrolled form state covers the write side.

Core Pattern

/**
 * Read the whole form at submit. FormData walks form.elements, so it picks up
 * every named, enabled control — including form-associated custom elements,
 * which is why this keeps working when a design-system field replaces an input.
 */
export function readForm<T extends Record<string, unknown>>(form: HTMLFormElement): T {
  const fd = new FormData(form);
  const out: Record<string, unknown> = {};

  for (const key of new Set(fd.keys())) {
    const values = fd.getAll(key);
    // A repeated name — a checkbox group, a multi-select, a repeated fieldset —
    // yields several entries. Collapsing them to the first silently loses data.
    out[key] = values.length > 1 ? values : values[0];
  }
  return out as T;
}

Two behaviours are worth knowing before relying on it. FormData omits disabled controls entirely, and it omits unchecked checkboxes — both by specification, and both usually what you want. It also returns everything as a string or a File, so coercion is the caller’s job:

// The DOM has no types. Coerce at the boundary, once, using the same schema
// the form validates with, so the payload and the validation agree.
const parsed = schema.safeParse(readForm(form));
if (!parsed.success) return renderErrors(parsed.error);
await submit(parsed.data);          // parsed.data, not the raw strings
What ends up in the payload, and what does not Included: any control with a name attribute that is enabled, every checked checkbox and radio, every selected option of a multiple select, files from file inputs, and form-associated custom elements that have called setFormValue. Excluded: disabled controls, controls with no name, unchecked checkboxes and radios, buttons other than the one that submitted, and any control outside the form element unless it carries a matching form attribute. Each exclusion is by specification rather than by accident, and each one is occasionally the cause of a missing field. included any named, enabled control checked checkboxes and radios every selected option of a multi-select files from file inputs form-associated custom elements The last row is why this pattern survives a design-system migration. excluded, by specification disabled controls controls with no name attribute unchecked checkboxes and radios buttons that did not submit controls outside the form element Each of these is occasionally the cause of a "missing field" report. One read, at one moment Before submit, nothing is read: the browser holds every value and no mirror exists. On submit, one FormData construction walks the form elements collection and produces every name and value pair in a single pass. The result is then coerced through the schema, which is the only place types are applied. Finally the parsed object is sent — never the raw strings, which would discard every coercion the schema performed. One read, at one moment while typing nothing is read the browser holds it on submit one FormData pass over form.elements coerce through the schema — the only typing step send parsed.data, never the raw strings The last box is the one that catches people: sending the raw values throws away every coercion the schema just did.

Step-by-Step Walkthrough

  1. Name every control. An unnamed input is invisible to FormData, which is the single most common cause of a field that “does not submit”.

  2. Read once, at submit. Not on change, not on blur — the whole point is that nothing is mirrored between keystrokes.

  3. Handle repeated names deliberately. getAll for anything that can repeat; get silently returns only the first.

  4. Coerce through the schema. The DOM produces strings; the API wants types. Doing it in the schema means the payload and the validation cannot disagree.

  5. Use readonly, not disabled, for fields you want submitted. A disabled field is excluded from the payload; a readonly one is included.

  6. Use the form attribute for controls outside the element. A submit button or field rendered in a portal or a sticky footer needs form="the-id" to participate.

Failure Modes and Edge Cases

1. Unchecked checkboxes vanish

An unchecked box contributes nothing, so "marketing" in payload is false rather than false. Where the API needs an explicit false, either add a hidden input with the same name before the checkbox — the checked box’s value then wins — or normalise after reading.

2. A disabled field the API requires

Disabling a field to prevent editing also removes it from the payload. readonly keeps it editable-looking-but-not and still submits it; for a genuinely locked value, add a hidden input.

3. Numbers, dates and booleans as strings

"7", "2026-08-05" and "on" are what the DOM gives you. Coercing in the schema keeps one definition; coercing ad hoc at the call site is where "0" becomes truthy.

4. Nested payloads

FormData is flat. A name like address.city or rows[1].postcode needs expanding into an object after reading — the same canonical path format used by normalizing nested field error paths, so errors and values agree.

5. The submitter button

new FormData(form) omits the button that submitted, which matters when a form has “Save” and “Save and add another”. Pass it explicitly: new FormData(form, event.submitter).

Four attributes that decide whether a control submits The name attribute is what puts a control in the payload at all; without it the control is invisible to FormData regardless of everything else. The disabled attribute removes a control from the payload entirely, which is why readonly is the right choice for a value that must be locked but still sent. The form attribute lets a control outside the form element participate, which is how a sticky footer submit button or a portalled field is included. And the value attribute on a checkbox decides what a checked box contributes, defaulting to the string on. Attribute Effect on the payload name required — without it the control is invisible to FormData disabled removes the control entirely; use readonly to lock and still send form="id" includes a control rendered outside the form element value, on a checkbox what a checked box contributes; defaults to "on" Three of these four produce a "field did not submit" report, and all three are invisible in the rendered page.

Verification Checklist

Common Pitfalls

  • Forgetting the name attribute. A control without one is invisible to FormData no matter what else is correct, and the symptom — one field missing from the payload — looks like a server problem.
  • Using get where the name can repeat. A checkbox group, a multi-select or a repeated fieldset yields several entries, and get silently returns the first. Use getAll for anything that can appear more than once.
  • Disabling a field you still need. disabled removes the control from the payload entirely. Use readonly for a value that must be locked and still submitted.
  • Coercing at the call site instead of in the schema. Two places that turn "0" into a number will eventually disagree, and one of them will treat it as truthy. Coerce once, where the rules already live.
  • Ignoring the submitter. new FormData(form) omits the button that submitted, so “Save” and “Save and add another” become indistinguishable. Pass event.submitter as the second argument.

Related

Controlled vs Uncontrolled Forms

Frequently Asked Questions

Does FormData work with React or Vue controlled inputs?

Yes — it reads the DOM, and a controlled input still has a DOM value. That makes it a useful escape hatch even in a fully controlled form: you can read the whole form in one call at submit rather than assembling a payload from state. The caveat is that if state and the DOM have diverged, FormData reports the DOM, which is what the reader actually sees and usually the more honest answer.

How do I get a boolean false out of an unchecked checkbox?

Either put a hidden input with the same name and a value of false immediately before the checkbox — the checked box’s entry then also appears and you take the last — or normalise after reading, filling in false for every checkbox name you know about. The second is clearer, because the hidden-input trick relies on document order and quietly breaks if the markup is reordered.

What about file inputs?

They appear as File objects in the FormData, and passing the FormData straight to fetch as a body sends them as multipart with no extra work. If you are building a JSON payload instead, pull the files out and upload them separately — trying to serialise a File into JSON silently produces an empty object.