🎓 What You Will Learn
- Controlled inputs: let React state be the single source of truth
- Every input type: text, textarea, select, and checkbox the controlled way
- Multi-field forms: one state object + one updater function
- Submitting forms:
onSubmitandpreventDefault() - Validation: deriving error messages from state, not storing them separately
- Uncontrolled inputs: when
useRefis the simpler choice
Controlled vs. Uncontrolled Inputs
In plain HTML, an <input> keeps its own value internally — the browser manages it. In React, you usually want the component's state to be the source of truth instead. That's a controlled input: its value comes from state, and every keystroke updates that state through onChange.
The alternative — letting the DOM hold the value and reading it only when you need it (with useRef) — is an uncontrolled input. Both are valid; controlled is the default choice because it makes validation, conditional disabling, and syncing fields with each other trivial.
Your First Controlled Input
The pattern is always the same: a piece of state, a value prop bound to it, and an onChange handler that updates it.
const [name, setName] = useState("");
<input
value={name} // 1. state drives the DOM
onChange={(e) => setName(e.target.value)} // 2. DOM updates the state
/>React state right now: (empty)
name is, you can transform, validate, or reject a keystroke before it ever reaches the screen — that's impossible with a plain uncontrolled input.Every Input Type, the Controlled Way
Text inputs use value / onChange. Checkboxes use checked instead of value. Selects and textareas follow the same value / onChange pattern as text inputs.
// Text
<input value={text} onChange={(e) => setText(e.target.value)} />
// Checkbox — use "checked", not "value"
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
// Select
<select value={plan} onChange={(e) => setPlan(e.target.value)}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
// Textarea — same API as a text input
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />Multi-Field Forms — One Object, One Updater
A separate useState per field works for 1–2 fields, but it gets noisy fast. For anything bigger, keep one state object and one generic updater that merges a single field's change in.
{
"plan": "free",
"bio": "",
"newsletter": true
}{ ...prev, [field]: value }. Writing form.bio = value directly won't trigger a re-render and can cause subtle state bugs.Handling Submit
Wrap your fields in a real <form> and handle onSubmit (not the button's onClick) so pressing Enter in any field also submits. Call e.preventDefault() first — otherwise the browser does a full page reload.
Validation — Derive, Don't Duplicate
Resist the urge to store the error message in its own useState. Compute it directly from the field's current value on every render instead — one source of truth, no risk of the error going stale.
touched (usually on onBlur) so users aren't yelled at before they've finished typing.When Uncontrolled Inputs Make Sense
For a simple one-off form — a search box you only read on submit, or a file input (whose value is read-only anyway) — a ref can be less code than wiring up state.
import { useRef } from 'react';
export function QuickSearch() {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log("Searching for:", inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="" />
<button type="submit">Search</button>
</form>
);
}Note defaultValue instead of value — that's what makes this uncontrolled. React sets the initial value and then leaves the DOM alone.
Common Mistakes
// ❌ BAD: React warns "a component is changing an uncontrolled input"
<input defaultValue={name} onChange={(e) => setName(e.target.value)} />
// ✅ GOOD: pick one — controlled...
<input value={name} onChange={(e) => setName(e.target.value)} />
// ...or fully uncontrolled
<input defaultValue={name} ref={inputRef} />// ❌ BAD: checkboxes don't read "value" for their checked state
<input type="checkbox" value={agreed} onChange={...} />
// ✅ GOOD: use "checked"
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />// ❌ BAD: browser does a full page reload on submit
const handleSubmit = () => {
saveForm();
};
// ✅ GOOD
const handleSubmit = (e) => {
e.preventDefault();
saveForm();
};Best Practices
onSubmitInput Type Reference
| Element | Value Prop | Change Handler | Notes |
|---|---|---|---|
| <input type="text"> | value | onChange → e.target.value | Most common case |
| <input type="checkbox"> | checked | onChange → e.target.checked | Not value! |
| <input type="radio"> | checked | onChange → e.target.value | Group by shared name |
| <select> | value | onChange → e.target.value | Works like a text input |
| <textarea> | value | onChange → e.target.value | No children, use value |
Practice Project
Build a small contact form that:
- Has name, email, message (textarea), and a "subscribe to updates" checkbox
- Uses one state object with a generic
updateFieldupdater - Shows a validation error if email is missing an
@, only afteronBlur - Calls
e.preventDefault()on submit and shows a "Message sent!" success state
What's Next?
- Conditional Rendering: show validation errors and success states the right way
- useState: deeper patterns for objects and arrays in state
- useContext: share form state across nested field components
- React Hooks Deep Dive: extract a reusable
useFormcustom hook
Keep practicing! 💪 Forms are one of the most common things you'll build in React — the controlled-input pattern shown here scales from a single search box to a full multi-step checkout flow.