The default advice for React forms is "controlled inputs," so most codebases end up with eight useState hooks, eight onChange handlers, and a form that re-renders its entire subtree on every keystroke of the ninth field. The advice optimizes for a problem most forms do not have.
The browser already knows what the user typed. React does not need a copy of it until submit.
Step 1: Know what controlled inputs cost
const [email, setEmail] = useState("");
<input value={email} onChange={(e) => setEmail(e.target.value)} />Every keystroke: setEmail, one re-render of this component and every child, one reconciliation pass. Fine for a contact form. Different story for the editable invoice with 40 line items, where each keystroke re-renders 40 rows of inputs. That form's lag is not React being slow. It is the app doing per-keystroke work that submit-time reading would do once.
Step 2: Default to uncontrolled with FormData
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const email = data.get("email");
}
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<button>Sign up</button>
</form>No state, no onChange, no re-render on any keystroke. Validation still happens (the browser validates required and type="email" before the handler even fires). React 19's <form action={fn}> is this exact pattern promoted to a first-class API, and it also resets the fields natively after a successful action.
Step 3: Reach for refs when you need one value imperatively
Half of the "I need controlled state" cases I review are actually one of these:
const searchRef = useRef<HTMLInputElement>(null);
searchRef.current?.focus(); // focus after modal opens
const term = searchRef.current?.value; // read on demandFocus after an error, reading a value in a non-submit handler, scrolling to the invalid field. A ref does it with zero re-renders. If nothing in your UI reacts to the value while typing, a ref or FormData covers it and controlled state is just ceremony.
Step 4: Reserve controlled inputs for live validation and conditional fields
Now the honest cases. A submit button disabled until the email matches a regex, a password strength meter, a "company" field that appears when "account type" flips to business: these react to the value per keystroke, and controlled state is the tool:
const [type, setType] = useState<"personal" | "business">("personal");
<select value={type} onChange={(e) => setType(e.target.value)}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{type === "business" && <input name="vat" />}One controlled field driving conditional logic, everything else uncontrolled. The cost is real but scoped to the field that earns it.
Step 5: Mix them per field, not per form
The mature pattern is a split form: uncontrolled text inputs for the bulk, one or two controlled fields where the UI genuinely reacts, a single submit reading FormData for everything. I audit forms this way, field by field: does anything on screen change while this input changes? No means uncontrolled. Yes means controlled. Most forms come out 90% uncontrolled, and the lag complaints disappear with the mirror state.
The rule I actually follow
Controlled is a feature, not a default. Start uncontrolled, and add controlled state to the specific fields whose values drive the UI. The form gets shorter, faster, and the submit handler becomes the only place that knows what the user typed, which is exactly where you needed it.
Shipping React forms that lag or sprawl with mirror state? This refactor is a quick win on most codebases. Tell me about your project and I will show you which fields actually need state.