In plain HTML an input owns its own value and you go and read it when you need it. In React you normally invert that: state owns the value, the input displays it. That is a controlled input, and it is what makes live validation, formatting, disabled submit buttons and a live price preview possible at all.
The pattern
Two props, always together: value and onChange.
const [email, setEmail] = useState('customer@pizza.test');
<Form.Control
id={`${formId}-email`}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>Each keystroke fires onChange, which sets state, which re-renders, which puts the new
value back in the input. It sounds like a lot of work per character and it is not — this is what
React is fast at.
Give value without onChange and the field is read-only.
The input is pinned to a value nothing ever changes, so typing does nothing. React warns about this
in development. If you genuinely want an uncontrolled input with a starting value, the prop is
defaultValue.
Never initialise state to undefined or null for a text
input. React treats a value of undefined as uncontrolled and then
switches to controlled on the first keystroke, which produces the warning "A component is
changing an uncontrolled input to be controlled". Start with ''.
The whole form
Here is a real one end to end — the sign-in page:
export function LoginPage() {
const { login, error, loading } = useAuth();
const navigate = useNavigate();
const formId = useId();
const [email, setEmail] = useState('customer@pizza.test');
const [password, setPassword] = useState('pizza123');
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
try {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch {
// The error message is already surfaced through AuthContext; nothing to do here.
}
}
return (
<Form onSubmit={handleSubmit}>
{error && <Alert variant="danger">{error}</Alert>}
<Form.Group className="mb-3">
<Form.Label htmlFor={`${formId}-email`}>Email</Form.Label>
<Form.Control
id={`${formId}-email`}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label htmlFor={`${formId}-password`}>Password</Form.Label>
<Form.Control
id={`${formId}-password`}
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</Form.Group>
<Button type="submit" variant="primary" className="w-100" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</Form>
);
}Five things in there are worth calling out.
onSubmit on the form, not onClick on the button.
Pressing Enter in a text field submits a form without clicking anything, so an onClick
handler silently excludes keyboard users. The button needs type="submit" for the same
reason.
event.preventDefault(). Without it the browser does a full page
navigation and your application restarts from scratch. See
Handling Events.
disabled={loading}. This is the payoff of controlled state — the
button knows about the in-flight request, so a slow network cannot produce three sign-in attempts.
required and type="email" are still there. Controlled
inputs do not replace the browser's own validation; use both.
autoComplete is set properly. username and
current-password are what password managers look for.
useId, for labels
A <label> needs an htmlFor matching the input's id, or
clicking the label does not focus the field and screen readers cannot announce it. But a hardcoded
id="email" breaks the moment two of that component render on one page.
useId generates a stable unique prefix:
const formId = useId();
<Form.Label htmlFor={`${formId}-email`}>Email</Form.Label>
<Form.Control id={`${formId}-email`} /* … */ />One useId per component, suffixed per field — that is the intended usage. It is
stable across re-renders and consistent between server and client rendering, which
Math.random() is not.
Do not use it for list keys. That is a different problem with a different answer: Rendering Lists and Keys.
Many fields, one handler
Separate useState calls are fine up to about four fields. Past that, one object and a
generic handler is less repetitive:
const [form, setForm] = useState({ street: '', city: '', state: '', zip: '' });
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;
setForm((current) => ({ ...current, [name]: value }));
}
<Form.Control name="street" value={form.street} onChange={handleChange} />
<Form.Control name="city" value={form.city} onChange={handleChange} />
<Form.Control name="zip" value={form.zip} onChange={handleChange} />[name]: value is a computed property key — the field's name attribute
selects which key to update. And note the updater function: the new state depends on the old, so
the updater form is the correct choice.
The other input types
Checkbox
Checkboxes use checked, not value, and read
e.target.checked:
const [saveCard, setSaveCard] = useState(false);
<Form.Check
type="checkbox"
id="save-card"
label="Save this card for next time"
checked={saveCard}
onChange={(e) => setSaveCard(e.target.checked)}
/>Radio
Several inputs share one name; each is checked when the state matches
its own value:
{crusts.map((option) => (
<Col key={option.id}>
<Form.Check
type="radio"
name="crust"
id={`crust-${option.id}`}
checked={crustId === option.id}
onChange={() => setCrustId(option.id)}
label={<span>{option.name}</span>}
/>
</Col>
))}Select
A select is controlled on the <select>, never on the options. Note the
Number(…) — every DOM value is a string, so a numeric field needs converting or you get
"2" where you wanted 2:
<Form.Select
id={quantityId}
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
style={{ maxWidth: '8rem' }}
>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</Form.Select>Textarea
In HTML the content sits between the tags. In React it is a value prop like
everything else:
<Form.Control as="textarea" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />Multi-select toggles
Toppings are not a form control at all — they are buttons backed by an array of ids, which is often the nicer interface:
const [selectedToppingIds, setSelectedToppingIds] = useState<string[]>([]);
function toggleTopping(id: string) {
setSelectedToppingIds((current) =>
current.includes(id) ? current.filter((t) => t !== id) : [...current, id],
);
}
<Button
type="button"
size="sm"
variant={selected ? 'primary' : 'outline-secondary'}
aria-pressed={selected}
onClick={() => toggleTopping(topping.id)}
>
{topping.name}
<span className="ms-1 small opacity-75">+{formatMoney(topping.price)}</span>
</Button>type="button" is not optional. A <button> inside a form defaults to
type="submit", so without it every topping click would submit the form.
aria-pressed is what tells a screen reader this is a toggle rather than an action.
What controlled state buys you
Because every choice in the builder is in state, the price can be derived from it and shown live as the user clicks:
const pricing = useMemo(() => {
if (!product) return { unit: 0, total: 0 };
const base = product.sizes.find((s) => s.size === size)?.price ?? 0;
const toppingsTotal = selectedToppings.reduce((sum, t) => sum + t.price, 0);
const unit = round2(base + (crust?.priceDelta ?? 0) + toppingsTotal);
return { unit, total: round2(unit * quantity) };
}, [product, size, crust, selectedToppings, quantity]);With uncontrolled inputs you would be reading the DOM on every change to do that. This is the argument for controlled inputs in one example.
When not to control
Two cases. A file input cannot be controlled — its value is read-only for security reasons, so use a ref. And once a form is genuinely large, re-rendering the whole thing on every keystroke starts to cost something; that is when people reach for React Hook Form, which keeps inputs uncontrolled and subscribes to them individually.
Neither applies to most forms. Start controlled.