Uncontrolled inputs and the FormData contract
How React 19 forms let the browser own field values and carry them to a Server Action through native FormData, keeping client state to almost nothing.
Picture the first form your app needs: a signup screen. It has email, password, name, company, a role dropdown, and a checkbox to agree to the terms. That’s six fields and a submit button. From the work in the previous two chapters, a Server Action is already written on the other side, waiting to validate the payload and write the row.
This whole chapter turns on one question, so let’s ask it now: what is the least client state this form needs to do its job?
If you’ve written React before 2020, or read a tutorial that was, you already know the answer you were taught. A useState for every field. A value and an onChange on every input. An onSubmit handler that calls event.preventDefault(), gathers the six state values into an object, and fires a fetch. Six fields, six pieces of state, six setters, and a re-render on every keystroke.
That’s the answer this lesson exists to replace. The 2026 default is close to the opposite of it, and once you see it, the old shape looks 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, which re-renders the component, so React owns every character the user types. None of that work is needed unless something on the screen has to react while they type.
function SignupForm() { return ( <form> <input name="email" type="email" defaultValue="" />
<input name="name" defaultValue="" />
<select name="role" defaultValue="member"> {/* options */} </select> </form> );}The relief. The DOM owns the live value, and React holds nothing. The values are read once, at submit, out of the form itself. The <form> stays bare for now; wiring its action is the next lesson. This is the default for almost every form you’ll write.
Look at what disappeared. Three state hooks, three setters, and the per-keystroke re-render are all gone, and the form still collects exactly the same values. That deletion is the point of this lesson. 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.
By the end of this lesson you’ll have two reflexes that run through every form the rest of the course writes. The first is uncontrolled by default: fields declare their initial value once and let the browser own the rest. The second is that name is the contract: the name attribute on an input is the single string that ties the form to the schema and the Server Action. With both in hand, you’ll be able to look at any form, predict exactly what data its submit produces, and read that data on the server. The action wiring that connects the two comes next lesson. This lesson is about the data the form carries.
Controlled vs uncontrolled: where each earns its weight
Section titled “Controlled vs uncontrolled: where each earns its weight”Those two words name a real fork in how an input works, so let’s pin both down before deciding which to reach for.
A controlled input binds its value to a piece of React state, and an onChange writes every keystroke back into that state. React is the source of truth for what’s rendered in the box, and the parent component can read the current value at any moment, because it is the current value. The price is structural: one state hook per field, and a re-render every time a character changes.
An uncontrolled input is the inverse. You give it a defaultValue to seed it on the first render, and from there the DOM owns the live value. React holds nothing about it. You read the value on demand: at submit time, or through a ref if you need it sooner. The price is the mirror image: the parent can’t react to the value as it changes without wiring something extra.
So neither is “better” in the abstract; they buy different things. The senior move is to know which thing you actually need, and the test is one question you can apply mechanically to any field:
Does some other part of the UI have to change while the user is typing?
If the answer is no, which it is for the ordinary create-or-edit form, uncontrolled wins, and the controlled machinery would be pure overhead. You’d be paying for a re-render per keystroke and getting nothing back, because nothing on the screen depends on those characters until the form is submitted.
If the answer is yes, then you reach for controlled, because now the live value genuinely has a job. The “yes” cases are worth memorizing, because they’re the only times the weight pays off:
- 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 shows a mismatch warning as you type.
Every one of those 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,” and so it’s uncontrolled.
This is the judgment the lesson is really teaching, so practice it directly. Sort each of these fields into the bucket it belongs in.
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.
Notice the pattern in the “controlled” column: it isn’t about the kind of field, it’s about 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 itself.
Why uncontrolled fits the Server Action seam
Section titled “Why uncontrolled fits the Server Action seam”There’s a deeper reason uncontrolled is the default here, beyond “fewer hooks.” Uncontrolled inputs line up perfectly with the mutation seam you already built.
Walk the chain. The Server Action from the last chapter takes a single argument, a FormData object, and its very first line turns it into a plain object with Object.fromEntries(formData). Now ask where that FormData comes from. The browser builds it natively from a form submit, and has done so since long before React existed, with no JavaScript required to assemble it. An uncontrolled input round-trips straight through that FormData with zero JavaScript holding the value, because the value was never in JavaScript to begin with. It was in the DOM, which is exactly what the browser serializes.
So the form is a Client Component, and it’ll get its 'use client' directive next lesson, but it carries almost no client state. The field values live in the DOM. The pending state (“is this submitting?”) will live in a hook called useActionState, two lessons from now. The error state lives in the Result the action returns. The form component itself holds essentially nothing.
That alignment is easiest to see as a picture. One string travels through three places without ever changing.
One name, three places: the input’s name, the FormData key, and the schema’s key are a single string that never changes shape as the data crosses the wire.
Read the diagram by color. The blue token is the string email, and it’s the same string whether it’s an HTML attribute, a key in the browser’s FormData, or a key in your Zod schema. The same holds for role in orange and company in violet. There’s no translation step anywhere in that flow: no mapping layer, no renaming. That identity is what makes the form, the action, and the schema fit together with no glue code. Let’s make it a rule.
name is the contract
Section titled “name is the contract”Here’s the rule, stated plainly: the input’s name, the FormData key, and the schema’s key are one set of strings, and you keep them literally identical. Drift between them, like a name="emailAddress" against a schema key of email, is the bug class this discipline exists to prevent. The code compiles, the form renders, and the field just quietly never arrives where you expected.
That word quietly is the part worth noting early:
So every input that should reach the action carries a name. 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, which is exactly the handoff the Server Action does before it validates.
The read side of that handoff is two lines, and it’s worth seeing exactly 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. The keys the schema declares are the same strings. That’s why safeParse(raw) just works: the object it receives is shaped like the object it expects, because both were written against one set of names. The rest of that action body, which authorizes the user, writes the row, revalidates, and returns a typed Result, is the work you already did in the previous chapter. Wiring the form to call this function at all is the next lesson. For now, the whole point is the alignment in those two lines.
defaultValue, never value, on uncontrolled inputs
Section titled “defaultValue, never value, on uncontrolled inputs”There’s one mistake nearly everyone makes exactly once with uncontrolled inputs, and it’s worth a moment to head off.
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 can edit freely, and you read the final value at submit. That’s exactly what you want.
What happens if you reach for value instead? Putting value on an input without an onChange pins it. You’ve told React “this is the value” but given React no way to update it, so the field freezes at that value: React renders it, the user types, and React renders it right back. In development you’ll 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 is short enough to keep in your head: defaultValue on the first render, no value prop after, for any uncontrolled field.
Test the reflex before it costs you a debugging session.
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?
user.email and behaves like a read-only field, with a React warning in the dev console.value and defaultValue are interchangeable.value prop with no onChange hands React a value it has no way to update, so after every keystroke React re-renders the field straight back to user.email — a frozen, read-only box, plus the “changing an uncontrolled input to be controlled” dev warning. The field still renders fine, and nothing here lets the DOM keep the typed text. The fix is one prop: defaultValue={user.email} seeds it once and then steps back, letting the DOM own what the user types.Every input shape and the FormData it produces
Section titled “Every input shape and the FormData it produces”Text inputs are the easy case: the value is a string, keyed by name, and that’s it. But a form also has selects, checkboxes, radios, and file pickers, and each of those lands in FormData in its own way. You need to predict these exactly, because the schema’s coercion was written against precisely these shapes. A checkbox doesn’t arrive as a boolean, and knowing what it arrives as is what lets the schema turn it into one.
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 plain 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 text content arrives as a string under notes. One React-specific gotcha: the initial value goes in defaultValue, not as children between the tags the way 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". defaultValue goes on the <select>, never as 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. This absence is exactly 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 the present value becomes "yes"; 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. The form needs multipart/form-data encoding for this. 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 the array ["urgent", "finance"], while formData.get('tags') returns only the last one. The name="tags[]" bracket convention you may have seen is just a literal string to FormData; the brackets carry no meaning to the platform. Reach for getAll regardless.
Two of those shapes trip people up, so it’s worth restating them. A checkbox is present or absent, never true or false: an unchecked box leaves no trace in FormData at all. And a group of inputs sharing a name is read with getAll, because get quietly hands back only the last value. Those are the non-obvious ones, and the schema’s design depends on you knowing them.
Now try to predict rather than recognize. 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 === ___;If you got the subscribe line right, where the answer is null because the unchecked box never made it into the object, you’ve internalized the one shape that catches everyone. The rest is mechanical from here.
Reading FormData: get, getAll, fromEntries
Section titled “Reading FormData: get, getAll, fromEntries”You now know what’s in the bag; here’s how you reach into it. There are exactly three reads, and the course treats each as the default for a specific job.
formData.get(name)returns a single value. If the name repeats, you get the last one; if it’s absent, you getnull. This is your read for ordinary single-value fields.formData.getAll(name)always returns an array. This is the explicit multi-value read, the one you use for anything that can repeat: multi-checkboxes, multi-selects.Object.fromEntries(formData)builds the flat object the schema parses. The catch is that it collapses repeated names to the last value, the same waygetdoes, because it can’t know a key was meant to be plural.
That last catch shapes the canonical pattern. For an ordinary flat form, Object.fromEntries(formData) is the whole handoff. The moment a field is multi-value, you build the flat object and then 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 the canonical entry into any action is the same two ideas: const raw = Object.fromEntries(formData), then Schema.safeParse(raw), reaching for getAll first on any field that can repeat. The schema does the rest: its coercion turns the strings into typed values, so even though the form sends strings (and Files), the action receives typed values after the parse. That’s the whole reason the names have to match the keys: the parse matches shapes by name, and a mismatched name is a key the schema never sees.
One more thing to name while it’s in front of you: FormData is the wire format here. It’s what the browser produces from a submit and what the action receives. It needs no Content-Type header, it carries files natively, and it’s the senior reach for your app’s own mutations over the old JSON.stringify + fetch reflex. The full “why not onSubmit + fetch” argument is the next lesson. For now, just note that you’re not building a request body by hand; the platform builds it for you.
When a value must drive the UI: the controlled escape, kept small
Section titled “When a value must drive the UI: the controlled escape, kept small”Here’s where people who just learned “controlled when the UI reacts” tend to over-correct. They want a character counter under the bio field, decide that counts as “the UI reacts,” and make the whole field, sometimes the whole form, controlled. That’s the wrong line. You can have the live readout without surrendering the input.
The move is to 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> </>);Look closely at what makes this safe: there’s an onChange, but there’s no value prop on the <textarea>. The handler reads the value off the event to update a sibling, the {count}/280 readout, and never writes it back into the field. So the DOM still owns the text, the field is still uncontrolled, and at submit the bio still rides through FormData like every other field. You get the reactive readout for the price of one piece of derived state, not by controlling the input. When you’d rather not even hold the count in state, a ref on the textarea lets you read its length on demand instead.
So when do you reach for a fully controlled input, the kind with a real value prop? Only when the input’s own rendered text has to be driven programmatically: a phone field that reformats 5551234567 into (555) 123-4567 as you type, or a wizard step whose field a parent component sets. Reacting to a sibling never requires it. And if a form’s field shape genuinely outgrows a flat FormData, with dynamic arrays of fields or a multi-step wizard with branching, that’s a different tool entirely: React Hook Form, which the next chapter covers. Until you hit that point, uncontrolled plus the occasional derived-state readout handles it.
Nested data: keep forms flat
Section titled “Nested data: keep forms flat”One last shape to know about, because you’ll eventually meet a schema that wants { address: { street, city } } and wonder how a form expresses nesting. The short answer is that it doesn’t, natively. FormData is flat: a string-to-value multimap with no concept of nested objects. So when the domain genuinely is nested, you reach for one of two encodings, and both are the exception, not 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. This is cheapest when the shape is a fixed handful of fields.
<input type="hidden" name="address" value={JSON.stringify(address)}/>Use this when the nested editing UI is itself stateful (a list-builder, a multi-step picker). Keep that sub-state controlled in the parent and serialize it to a hidden input on submit. This is the one place a value prop is correct, since the hidden input is genuinely controlled by the parent’s state, and the action JSON.parses it before validating.
The reflex to keep: forms stay flat; you reach for nested encoding only when the domain genuinely is nested. Use dot-keys when the nested shape is small and static, and a hidden JSON field when the thing editing it is itself a stateful sub-component. And when even that starts to strain, with deeply dynamic, many-level forms, you’ve crossed into React Hook Form territory, which the next chapter picks up.
What the form already does for free
Section titled “What the form already does for free”Step back and look at what you can now build. You have a form of uncontrolled inputs, each with the right name, each declaring its initial value with defaultValue, read on the server with get / getAll / Object.fromEntries and handed straight to a schema. That form carries almost no client state, and that’s not a minor efficiency win: it’s the foundation of the rest of this chapter.
Because the form holds so little, it behaves like a plain server-rendered HTML form, and that single property is what buys you everything coming next. It’s what lets React reset the form automatically after a successful submit and wire the action prop in the next lesson. It’s what lets useActionState layer pending and error rendering on top a lesson later. And it’s what makes the form keep working even with JavaScript disabled, which is progressive enhancement, the topic that closes out the chapter, without you writing a line of extra code for it. The point to carry forward is exactly that: the lighter the client state, the more the platform does for you.
Next lesson, the form learns to talk: you’ll wire the action prop so a submit actually calls the Server Action you’ve been handing FormData to all along.
External resources
Section titled “External resources”The canonical reference on controlled vs uncontrolled inputs, the defaultValue prop, and why a field can't switch between the two.
The full method list (get, getAll, entries) and how the browser builds a FormData object natively from a form submit.
Lee Robinson (Next.js team) builds a form end to end, the direction the next few lessons take this one (9 min).
Brad Westfall's deep dive on reading FormData with TypeScript and the gotchas of Object.fromEntries, the article behind the video above.