Controlled vs uncontrolled forms
Controlled inputs let React own each value (value + onChange) for live validation or value-driven UI; uncontrolled inputs let the DOM own them, read once via FormData or a ref — fewer re-renders, less code. Default to uncontrolled for plain submit-only forms.
The reflex you picked up in the React track is to wire every input as value={x} onChange={...}. It feels like the “React way”: state in, state out, fully in control. So a signup form gets eight useState calls and eight onChange handlers, and every keystroke in any field re-renders the whole form component — including the seven fields the user isn’t touching.
Most of that control buys nothing. The form has one moment that matters — submit — and you only need the values then. There’s a leaner default hiding in plain sight: let the DOM hold the text, and read it once when the form is sent. Knowing which inputs actually need React to own their value during typing is the senior call this lesson is about.
After this lesson you can distinguish a controlled input (React owns the value via value + onChange, re-rendering on each keystroke) from an uncontrolled one (the DOM owns the value, read on submit via FormData or a ref); choose controlled only when the value drives other UI or needs live validation; default to uncontrolled + FormData for plain submit-only forms; read a React 19 form action that gets FormData for free; and name the failure mode — controlling every input by reflex and re-rendering a large form on every keystroke.
Controlled means React is the single source of truth for the value — value plus onChange, and a re-render per keystroke. The input shows exactly what state says; typing fires onChange, you update state, React re-renders, the input shows the new value. That round-trip is the point: because state mirrors the field on every key, you can react to the value while the user types — validate it, transform it, drive other UI from it.
function ControlledEmail() {
const [email, setEmail] = useState("");
const invalid = email.length > 0 && !email.includes("@");
return (
<>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{invalid && <p role="alert">Email needs an @</p>}
</>
);
}The cost is explicit: every keystroke is a state update and a render. For one field that’s nothing. For a 20-field form where all the state sits in one component, every key re-renders all 20.
Uncontrolled means the DOM owns the value — you don’t track it, you read it once on submit. No value, no onChange, no state, no per-keystroke render. The browser does what browsers have always done: hold the text in the field. You set the initial value with defaultValue (not value), and you retrieve the final value when it matters — at submit.
function UncontrolledEmail() {
const ref = useRef<HTMLInputElement>(null);
function submit() {
console.log(ref.current?.value); // read once, on demand
}
return <input ref={ref} defaultValue="" name="email" />;
}Typing here causes zero React renders — the component renders once and stays put while the user fills the whole form. That is the leaner default, and for most forms it’s also less code: no state, no handler per field.
For submit-only forms, skip the ref too — read every field at once with FormData. A ref per input is fine for one or two fields, but it’s still boilerplate that scales linearly. The browser already collects every named field for you. Give each input a name, attach one onSubmit to the <form>, and construct FormData from the form element. One handler, any number of fields, no per-field wiring.
function SignupForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const email = data.get("email"); // typed values, read once
const password = data.get("password");
// ...send to the server
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" />
<input name="password" type="password" />
<button>Sign up</button>
</form>
);
}Ten fields cost the same as two: no extra state, no extra refs, no extra renders. This is the pattern a plain form should reach for first.
React 19 makes the uncontrolled default first-class: a form action receives FormData directly. Instead of onSubmit + preventDefault + manual new FormData, you pass a function to the form’s action prop and React calls it with the populated FormData. Pair it with useActionState and you get pending state and a returned result without controlling a single input — the framework is built around the uncontrolled-plus-FormData shape, not the controlled one.
"use client";
import { useActionState } from "react";
async function signup(_prev: string | null, formData: FormData) {
const email = formData.get("email");
// call the server, return an error string or null
return email ? null : "Email is required";
}
function SignupForm() {
const [error, action, pending] = useActionState(signup, null);
return (
<form action={action}>
<input name="email" type="email" />
<button disabled={pending}>{pending ? "..." : "Sign up"}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}Notice none of these inputs are controlled. The value you need arrives in formData at exactly the moment you need it.
A profile form, controlled by reflex, then fixed. The “before” controls all five fields in one component — the senior-track default mistake.
// before: every keystroke re-renders all five fields
function ProfileForm() {
const [name, setName] = useState("");
const [bio, setBio] = useState("");
const [city, setCity] = useState("");
const [site, setSite] = useState("");
const [phone, setPhone] = useState("");
return (
<form onSubmit={/* read 5 state vars */ () => {}}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />
<input value={city} onChange={(e) => setCity(e.target.value)} />
<input value={site} onChange={(e) => setSite(e.target.value)} />
<input value={phone} onChange={(e) => setPhone(e.target.value)} />
<button>Save</button>
</form>
);
}None of these values drive other UI or need live validation. They’re only read on save — so the control is pure overhead: five useState, five handlers, a full re-render on every key in any field. The fix is uncontrolled + FormData.
// after: zero state, zero per-keystroke renders
function ProfileForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const d = new FormData(e.currentTarget);
save({
name: d.get("name"),
bio: d.get("bio"),
city: d.get("city"),
site: d.get("site"),
phone: d.get("phone"),
});
}
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue="" />
<textarea name="bio" defaultValue="" />
<input name="city" defaultValue="" />
<input name="site" defaultValue="" />
<input name="phone" defaultValue="" />
<button>Save</button>
</form>
);
}Same behaviour, fewer lines, and the component renders exactly once for the whole editing session. Now suppose one field does need live feedback — a username that shows “taken / available” as you type. You’d make only that field controlled and leave the other four uncontrolled. That’s the senior posture: uncontrolled by default, controlled surgically where the value is genuinely needed during typing.
▸Why this works
Why is the per-keystroke re-render usually fine until it isn’t? React renders are cheap individually, and a controlled input on a small form is imperceptible. The cost shows up when all the state lives in one big component: a single setState re-renders that component and its whole subtree, so one keystroke can re-render dozens of fields plus any expensive siblings (a live preview, a chart, a heavy list). You can rescue a controlled form by colocating each field’s state in its own child component so a keystroke re-renders only that field — but if you don’t need the value during typing at all, uncontrolled skips the entire question.
▸Common mistake
The failure mode is controlling every input by reflex. It looks harmless on the form you’re building today, then someone adds a live-preview pane or the form grows to 30 fields, and suddenly every keystroke janks because a full re-render runs on each key. The tell is a form component stuffed with useState/onChange pairs whose values are only ever read inside onSubmit. If a value is read only at submit, it never needed to be state. Reach for controlled when the value drives other UI or needs validation as the user types — otherwise let the DOM hold it.
You're building a contact form: name, email, message — the values are only ever read when the user clicks Send, with no live validation and nothing else on the page depending on them. What's the senior default?
A controlled input makes React the source of truth — value + onChange, a re-render per keystroke — which you want only when the value drives other UI or needs live validation while the user types. An uncontrolled input lets the DOM own the value: set defaultValue, then read it once on submit via a ref or, better for multi-field forms, a single FormData over the named inputs. React 19 leans into this — a form action receives FormData directly and pairs with useActionState for pending and result state, all without controlling a single field. The senior default is uncontrolled + FormData for plain submit-only forms (less code, far fewer renders) and controlled surgically for the one or two fields that truly need the value during typing. The failure mode is the opposite reflex — controlling everything — which re-renders a large form on every keystroke for values you only ever read at submit.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.