Skip to content
Chapter 44Lesson 1

Uncontrolled inputs and the FormData contract

In React 19 forms, the browser owns each field value and carries it to a Server Action through native FormData, with almost no client state.

Picture the first form your app needs: a signup screen with email, password, name, company, a role dropdown, and a terms checkbox. Six fields and a submit button. From the previous two chapters, a Server Action already waits on the other side to validate the payload and write the row.

The whole chapter turns on one question: what is the least client state this form needs to do its job?

One common answer reaches for useState on every field, a value and onChange on every input, and an onSubmit handler that calls event.preventDefault(), gathers the six values into an object, and fires a fetch. Six fields, six pieces of state, six setters, and a re-render on every keystroke.

The other answer is close to its opposite, and it makes the first one look like a lot of machinery doing nothing. Here are the same fields, both ways.

function SignupForm() {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState('member');
return (
<form>
<input value={email}
onChange={(e) => setEmail(e.target.value)} />
<input value={name}
onChange={(e) => setName(e.target.value)} />
<select value={role}
onChange={(e) => setRole(e.target.value)}>
{/* options */}
</select>
</form>
);
}

The weight being removed. Every keystroke runs a setter and re-renders the component, so React owns every character the user types. None of that is needed unless something on screen has to react while they type.

Three state hooks, three setters, and the per-keystroke re-render are gone, and the form still collects the same values. The keyword is uncontrolled: the input keeps its own value in the DOM, the way a plain HTML form always has, and React stops trying to be the source of truth for text it isn’t doing anything with.

This lesson leaves you with two reflexes that run through every form the rest of the course writes. The first is uncontrolled by default: a field declares its initial value once and lets the browser own the rest. The second is that name is the contract: the name attribute is the single string tying each input to the schema and the Server Action. With both, you can look at any form, predict exactly what its submit produces, and read that data on the server. Wiring the action that connects the two comes next lesson; this one is about the data the form carries.

Controlled vs uncontrolled inputs, and when to use each

Section titled “Controlled vs uncontrolled inputs, and when to use each”

These two words name a real fork in how an input works, so define both before deciding which to reach for.

A controlled input binds its value to React state, and an onChange writes every keystroke back into that state. React is the source of truth for what’s in the box, and the parent can read the current value at any moment because it holds that value. The cost is one state hook per field and a re-render on every character.

An uncontrolled input is the inverse. You give it a defaultValue to seed the first render, and from there the DOM owns the live value while React holds nothing about it. You read the value on demand: at submit time, or through a ref if you need it sooner. The cost is the mirror image: the parent can’t react to the value as it changes without wiring something extra.

Neither is better in the abstract; they buy different things, and one question tells you which you need:

Does some other part of the UI have to change while the user is typing?

If no, which is the case for an ordinary create-or-edit form, uncontrolled wins. The controlled machinery would be pure overhead: a re-render per keystroke that buys nothing, because nothing on screen depends on those characters until the form is submitted.

If yes, reach for controlled, because the live value now has a job:

  • A search box that filters a list as you type (the list re-renders on each keystroke).
  • A dependent dropdown, where the options in field B depend on what’s selected in field A.
  • Conditional UI driven by an input, where an extra field shows only when a certain value is typed.
  • A live cross-field check, like a “confirm password” box that flags a mismatch as you type.

Each of these has something else on screen reacting to the keystroke. Everything else, like the email field, the notes box, or the company name, is a no, so it stays uncontrolled.

Practice the judgment directly. Sort each field into its bucket.

Apply the threshold to each field: does another part of the UI have to change as the user types? Drag each item into the bucket it belongs to, then press Check.

Uncontrolled fits Nothing else reacts while typing
Needs controlled Another part of the UI reacts per keystroke
The email field on a signup form
A search box that filters a list as you type
A country select that changes which state/province options appear
The notes textarea on an edit form
A password field
A confirm-password field that shows a mismatch warning live

The “controlled” column isn’t about the kind of field, but whether something watches it. A password field is uncontrolled on its own; the moment a sibling has to show “passwords don’t match” while you type, that sibling needs the live value, so the field goes controlled. The trigger is the reaction, never the field type.

Uncontrolled inputs round-trip through FormData

Section titled “Uncontrolled inputs round-trip through FormData”

Uncontrolled is the default here for a reason deeper than “fewer hooks”: it matches the mutation seam you already built.

The Server Action from the last chapter takes one argument, a FormData object, and its first line turns it into a plain object with Object.fromEntries(formData). The browser builds that FormData natively from a form submit, no JavaScript required. An uncontrolled input round-trips straight through it with zero JavaScript holding the value, because the value was never in JavaScript to begin with. It lived in the DOM, which is exactly what the browser serializes.

So the form is a Client Component, but it carries almost no client state: the field values live in the DOM, not in a hook. Later lessons add the 'use client' directive and a hook for pending state; the action’s Result carries any errors.

One string crosses all three places unchanged.

1 Form
<input name="email" />
<input name="role" />
<input name="company" />
2 FormData
email → 'ada@…'
role → 'admin'
company → 'Acme'
3 Schema
{
email: …,
role: …,
company: …,
}

The input’s name, the FormData key, and the schema’s key are one string, shown in a shared color across all three panels: blue email, orange role, violet company. The data crosses from form to schema with no renaming.

Read the diagram by color. email in blue is the same string as an HTML attribute, a FormData key, and a Zod schema key; role in orange and company in violet do the same. No mapping layer, no renaming. That identity is what lets the form, the action, and the schema fit together with no glue code.

The input’s name, the FormData key, and the schema’s key are one set of strings, kept literally identical. Drift between them, a name="emailAddress" against a schema key of email, is the bug class this discipline prevents: the code compiles, the form renders, and the field just never arrives where you expected.

So every input that should reach the action carries a name, and on the server that name is how you read it back. formData.get('email') returns the value of the input whose name="email", and Object.fromEntries(formData) builds the whole { email, password, … } object keyed by those names in one call.

That read side is two lines, and it’s worth seeing where the names land:

export async function createAccount(
formData: FormData,
): Promise<Result<Account>> {
const raw = Object.fromEntries(formData);
const parsed = createAccountSchema.safeParse(raw);
// Authorize, mutate, revalidate, return a Result — the previous chapter's job.
}

The keys of raw are the form’s names, and the keys the schema declares are the same strings, so safeParse(raw) receives the object it expects. The rest of the action body, authorizing, writing the row, revalidating, returning a typed Result, is the previous chapter’s work; wiring the form to call this function is the next lesson.

defaultValue, never value, on uncontrolled inputs

Section titled “defaultValue, never value, on uncontrolled inputs”

Edit forms need to render with the existing data already filled in. The instinct, carried over from controlled forms, is to write value={user.email}. Resist it: the correct prop is defaultValue.

<input name="email" defaultValue={user.email} />

defaultValue seeds the field on the first render and then steps back. The DOM owns it, the user edits freely, and you read the final value at submit.

value without an onChange does the opposite. You’ve told React “this is the value” but given it no way to update that value, so the field freezes: the user types, and React renders user.email right back. In development you get a console warning about an input switching between controlled and uncontrolled; in production you get a read-only field and a confused user. The rule fits in your head: defaultValue on the first render, no value prop after, for any uncontrolled field.

This field is meant to seed an edit form and stay editable, so the user can change the email before submitting:

<input name="email" value={user.email} />

When the user clicks in and starts typing, what actually happens?

Their keystrokes don’t stick — the box snaps back to user.email and behaves like a read-only field, with a React warning in the dev console.
Editing works as intended; for the initial render value and defaultValue are interchangeable.
The text changes normally, since the DOM is still the source of truth for an uncontrolled input.
The component crashes on render and the form never appears.

A text input is the easy case: its value is a string, keyed by name. But a form also has selects, checkboxes, radios, and file pickers, and each lands in FormData differently. Knowing exactly what each one produces is what lets the schema coerce it, since the coercion was written against these precise shapes. A checkbox, for one, never arrives as a boolean.

Here’s one form covering every shape you’ll meet; step through it and watch what each field contributes to the FormData.

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

Text input. A text or email input shows up as a string under its name: email → "ada@example.com". (Every input pairs with a <label> in production; they’re dropped here only to stay under the line cap.)

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

Textarea. Its content arrives as a string under notes. The React gotcha: the initial value goes in defaultValue, not as children between the tags as raw HTML does it.

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

Select. The selected option’s value lands under status, here "draft". Put defaultValue on the <select>, never a selected attribute on an <option>.

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

Checkbox: the quirk. A checked box sends the string "on" under archived. An unchecked box is absent from FormData entirely: not false, not "off", just gone. That absence is why the schema preprocesses it with z.preprocess(v => v === 'on' || v === true, z.boolean()), the checkbox shape from the previous chapter. Set value="yes" and a checked box sends "yes" instead; the default is "on".

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

Radio group. Two radios sharing one name are a single field: only the selected radio’s value lands under role. Seed the default with defaultChecked on one, not defaultValue on the group.

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

File input. Sends a File object under avatar, not a string. This needs multipart/form-data encoding; the action prop sets it, and you can also set encType explicitly for the no-JavaScript path. The schema validates it with z.instanceof(File).

<form>
<input name="email" type="email" defaultValue="ada@example.com" />
<textarea name="notes" defaultValue="Net 30 terms" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="sent">Sent</option>
</select>
<input type="checkbox" name="archived" />
<input type="radio" name="role" value="admin" defaultChecked />
<input type="radio" name="role" value="member" />
<input type="file" name="avatar" />
<input type="checkbox" name="tags" value="urgent" />
<input type="checkbox" name="tags" value="finance" />
</form>

Multi-value. Two checkboxes sharing the name tags can both be checked. formData.getAll('tags') returns ["urgent", "finance"]; formData.get('tags') returns only the last one. A name="tags[]" convention is just a literal string to FormData; the brackets mean nothing here, so reach for getAll regardless.

1 / 1

Two shapes are non-obvious, and the exercise below turns on both. A checkbox is present or absent, never true or false; an unchecked box leaves no trace. A group of inputs sharing a name is read with getAll, because get returns only the last value.

This time predict each result before you check. Here’s a filled-in form and the FormData it produces; fill in each blank from the options.

Given this submitted form, predict each FormData read. Pick the right option from each dropdown, then press Check.

// Submitted form state:
// <input name="email" /> entered "leo@acme.com"
// <input type="checkbox" name="subscribe" /> left unchecked
// <input type="checkbox" name="terms" /> checked
// <input type="radio" name="plan" value="pro" /> selected
// <input type="radio" name="plan" value="free" /> not selected
// <input type="checkbox" name="addons" value="sms" /> checked
// <input type="checkbox" name="addons" value="fax" /> checked
formData.get('email') === '___';
formData.get('subscribe') === ___;
formData.get('terms') === '___';
formData.get('plan') === '___';
formData.getAll('addons').length === ___;

The subscribe line answers null because the unchecked box never entered the object. Get that one and you’ve internalized the shape that catches everyone; the rest is mechanical.

Reading FormData: get, getAll, fromEntries

Section titled “Reading FormData: get, getAll, fromEntries”

There are exactly three reads, each the default for a specific job.

  • formData.get(name) returns a single value: the last one if the name repeats, null if it’s absent. Use it for ordinary single-value fields.
  • formData.getAll(name) always returns an array. Use it for anything that can repeat: multi-checkboxes, multi-selects.
  • Object.fromEntries(formData) builds the flat object the schema parses. It collapses repeated names to the last value, just like get, since it can’t know a key was meant to be plural.

That last point shapes the pattern. For a flat form, Object.fromEntries(formData) is the whole handoff. The moment a field is multi-value, build the flat object and override that one key with its getAll array before parsing:

const raw = Object.fromEntries(formData);
const parsed = createAccountSchema.safeParse({
...raw,
tags: formData.getAll('tags'),
});

So every action opens the same way: Object.fromEntries(formData), then Schema.safeParse(raw), reaching for getAll first on any field that can repeat. The form sends strings (and Files); the schema’s coercion turns them into typed values, so the action receives typed values after the parse.

Worth naming now: FormData is the wire format. The browser produces it from a submit and the action receives it, with no Content-Type header to set and files carried natively. You don’t build a request body by hand; the platform builds it for you. Why this beats an onSubmit + fetch of your own is the next lesson.

A live readout without controlling the input

Section titled “A live readout without controlling the input”

A character counter under a bio field looks like a case for a controlled input, but it isn’t. You can have the live readout without surrendering the field. Put useState only on the derived value, the count, and update it from an onChange, while the input keeps its defaultValue and stays uncontrolled:

const [count, setCount] = useState(0);
return (
<>
<textarea
name="bio"
defaultValue=""
maxLength={280}
onChange={(e) => setCount(e.target.value.length)}
/>
<span>{count}/280</span>
</>
);

There’s an onChange but no value prop, so the handler only updates a sibling, the {count}/280 readout, and never writes back into the field. The DOM still owns the text, the field stays uncontrolled, and the bio rides through FormData like any other field at submit. You get the reactive readout for one piece of derived state, not by controlling the input. To avoid even that state, a ref on the textarea reads its length on demand.

Reach for a fully controlled input, the kind with a real value prop, only when the input’s own rendered text must be driven programmatically: a phone field that reformats 5551234567 into (555) 123-4567 as you type, or a wizard step a parent component sets. Reacting to a sibling never requires it.

Eventually a schema wants { address: { street, city } }, and a form can’t express that nesting natively. FormData is flat: a string-to-value multimap with no concept of nested objects. When the domain genuinely is nested, you reach for one of two encodings, both the exception rather than the default.

<input name="address.street" defaultValue={addr.street} />
<input name="address.city" defaultValue={addr.city} />

Use this when the nesting is shallow and static. The names stay flat strings, and a small /lib helper or the schema itself rebuilds the nested object after Object.fromEntries. Cheapest for a fixed handful of fields.

The reflex to keep: forms stay flat; reach for nested encoding only when the domain genuinely is nested. Past that, with deeply dynamic, many-level forms, you’ve crossed into React Hook Form territory, which the next chapter picks up. Next lesson the form learns to talk: you’ll wire the action prop so a submit calls the Server Action.