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
Step-by-Step Walkthrough
-
Name every control. An unnamed input is invisible to
FormData, which is the single most common cause of a field that “does not submit”. -
Read once, at submit. Not on change, not on blur — the whole point is that nothing is mirrored between keystrokes.
-
Handle repeated names deliberately.
getAllfor anything that can repeat;getsilently returns only the first. -
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.
-
Use
readonly, notdisabled, for fields you want submitted. A disabled field is excluded from the payload; a readonly one is included. -
Use the
formattribute for controls outside the element. A submit button or field rendered in a portal or a sticky footer needsform="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).
Verification Checklist
Common Pitfalls
- Forgetting the
nameattribute. A control without one is invisible toFormDatano matter what else is correct, and the symptom — one field missing from the payload — looks like a server problem. - Using
getwhere the name can repeat. A checkbox group, a multi-select or a repeated fieldset yields several entries, andgetsilently returns the first. UsegetAllfor anything that can appear more than once. - Disabling a field you still need.
disabledremoves the control from the payload entirely. Usereadonlyfor 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. Passevent.submitteras the second argument.
Related
- Controlled vs Uncontrolled Forms — the ownership decision behind this
- Best Practices for Uncontrolled Form State — the write side
- Form-Associated Custom Elements with ElementInternals — why custom fields appear here
← 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.