#Controlled forms
A controlled input is one whose value lives in React state: you pass value={…} and update the state in onChange. React is the single source of truth — the DOM input only ever shows what state says. An uncontrolled input keeps its own value in the DOM, and you read it when you need it (with a ref, or from FormData on submit). The basics, and why a controlled input "can't be typed into" without onChange, are in Controlled vs uncontrolled components; this topic is about whole forms.
Mental model: a controlled form is a puppet — every movement of the input comes from React pulling the strings (state → value), and each keystroke is a request to move ("please set this to ab") that React may accept, change or refuse. An uncontrolled form is a notepad the user writes on freely; you only read the page when they hand it in.
A real form has several fields and several input types. The usual pattern is one state object and one handler keyed by the input's name:
import { useState } from "react";
const initial = { name: "", email: "", plan: "free", agree: false };
export function SignupForm({ onSubmit }) {
const [values, setValues] = useState(initial);
// One handler for every field, keyed by the input's name
function handleChange(e) {
const { name, type, value, checked } = e.target;
setValues((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
}
function handleSubmit(e) {
e.preventDefault(); // stop the browser's full-page form submission
onSubmit(values);
setValues(initial); // controlled, so resetting state resets the inputs
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input name="name" value={values.name} onChange={handleChange} />
</label>
<label>
Email
<input name="email" type="email" value={values.email} onChange={handleChange} />
</label>
<label>
Plan
<select name="plan" value={values.plan} onChange={handleChange}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
</label>
<label>
<input name="agree" type="checkbox" checked={values.agree} onChange={handleChange} />
I agree to the terms
</label>
<button type="submit" disabled={!values.agree}>
Sign up
</button>
<p>Preview: {values.name || "(no name)"} on {values.plan}</p>
</form>
);
}
// Test: type "Rohit", "r@example.com", choose Pro → "Preview: Rohit on pro"
// tick the box, submit → onSubmit({ name: "Rohit", email: "r@example.com", plan: "pro", agree: true })
// afterwards the Name input is empty againvaluesstarts as{ name: "", email: "", plan: "free", agree: false }. Each input reads its slice: text inputs and the<select>usevalue, the checkbox useschecked. The "Sign up" button is disabled becauseagreeisfalse.- Typing "R" in Name fires
onChange.handleChangereadsname = "name"andvalue = "R"from the event target and builds a new object with[name]: value(a computed key), sovalues.namegoes"" → "R". React re-renders and the input showsR. Each keystroke repeats this: the input is only ever showing state. - Choosing "Pro" works the same way — a controlled
<select>usesvalueon the select itself, notselectedon an option. The preview paragraph updates live (Preview: Rohit on pro), which is the main thing controlled forms give you: the current values are always in state, ready to render, validate or derive from. - The checkbox's
type === "checkbox"branch storeschecked(true), notvalue(which would be the string"on"). Now!values.agreeisfalseand the button enables. - Submitting calls
handleSubmit.e.preventDefault()stops the browser's default behaviour (navigating to the form'sactionURL and reloading the page). It passes the wholevaluesobject up, then resets state toinitial; because the inputs are controlled, they clear themselves. - The updater form
setValues((prev) => …)matters if two fields could change in the same event (autofill fires severalchangeevents at once); spreading a stalevalueswould drop one of them.
From my notes, the uncontrolled version of a single field reads the DOM through a ref only when needed:
import { useRef } from "react";
export function UncontrolledInput({ onSubmit }) {
const inputRef = useRef(null);
const handleSubmit = () => {
onSubmit(inputRef.current.value); // read the DOM only when needed
};
return (
<>
<input type="text" ref={inputRef} defaultValue="" aria-label="name" />
<button onClick={handleSubmit}>Submit</button>
</>
);
}
// Type "hi", click Submit → onSubmit("hi"); the component rendered once- The input has no
valueprop, so the browser owns its value.defaultValuesets only the initial value (React won't touch it again). - Typing changes the DOM input directly. React isn't involved: no state update, no re-render.
- On click,
inputRef.currentis the real<input>element, and.valueis whatever the user typed ("hi"). - The trade-off is visibility: React doesn't know the value until you ask, so you can't render a live preview or disable a button based on it without extra work.
Every keystroke in a controlled input is a state update and a re-render of the component that owns the state — and, by the waterfall rule, of everything it renders (see Why components re-render). Typing "hello" into a minimal controlled input rendered its component 6 times (mount + 5). In a 40-field form whose state lives at the top, that's the whole form re-rendering per keystroke. Usually it's still fast enough; when it isn't, the options are splitting fields into memoized components, the React Compiler, or an uncontrolled approach such as React Hook Form, which rendered zero extra times while typing in the test in React Hook Form.