Lesson 5 • React
Forms and Controlled Components ⚛️
By the end of this lesson you'll be able to wire any input to React state, manage a whole form with one state object, handle submit without a page reload, and validate fields with clear error messages — the skills behind every login, signup, and checkout screen.
What You'll Learn in This Lesson
- Build controlled inputs that bind value + onChange to state
- Store a single field with useState — and many fields in one object
- Update one field of a state object without losing the others
- Handle submit and stop the page reload with preventDefault
- Wire up text, checkbox, select, and radio inputs correctly
- Write a validation function that returns clear error messages
useState. Our editor runs plain JavaScript, so the runnable boxes simulate the state logic; the JSX you see in grey panels is the real component code you'd write in a Vite/React project.ref) when you need to read it. This lesson teaches the puppet, because it's the React way: state is the single source of truth.1️⃣ Controlled Inputs: value + onChange
A controlled input gets its displayed text from React state, and reports every keystroke back to state. Two props make this happen: value={name} tells the input what to show, and onChange runs on every keystroke so you can call setName. Miss either one and the input either won't update or won't be controlled. Here's the smallest complete example — read it first.
function NameForm() {
// useState holds the input's value. '' is the starting value.
const [name, setName] = useState('');
return (
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
value={name} // React DRIVES the input
onChange={(e) => setName(e.target.value)} // every keystroke updates state
placeholder="Enter your name"
/>
{/* {name || 'stranger'} re-renders live as you type */}
<p>Hello, {name || 'stranger'}!</p>
</div>
);
}It's a loop on every keystroke: type → onChange → setState → re-render → the new value appears. State is never "behind" — it is what the box shows. The runnable box below walks through that round-trip one key at a time. Run it and watch state catch up.
Worked example: the controlled-input loop
Run it and watch state update one keystroke at a time.
// The controlled-input loop, simulated step by step.
// In a real component each "render" is React redrawing the input.
let state = ""; // pretend this is useState('')
function setState(next) { state = next; } // pretend this is setName(...)
// A user types "Hi" one key at a time:
for (const key of ["H", "i"]) {
// 1) onChange fires with the would-be new value
const eventValue = state + key;
// 2) you call setState -> React stores the new value
setState(event
...2️⃣ One State Object for Many Fields
For a single box, one useState('') per field is fine. But a login form has an email and a password, and a separate useState for each gets noisy fast. The cleaner pattern is one object — { email: '', password: '' } — updated with a computed key. A computed key is the [name] in { [name]: value }: the square brackets mean "use the value of name as the key", so one handler can update whichever field changed.
function SignUp() {
// ONE object holds every field. Keys match each input's "name".
const [form, setForm] = useState({ email: '', password: '' });
// One handler for ALL inputs. [name] is a COMPUTED key.
function handleChange(e) {
const { name, value } = e.target; // which input changed
setForm(prev => ({ ...prev, [name]: value })); // copy, then override one
}
return (
<form>
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" value={form.password} onChange={handleChange} />
</form>
);
}The golden rule: never mutate the old object. Always spread the previous values (...prev) first, then override the one field that changed. Skip the spread and you wipe out every other field — run this to see exactly how that happens.
Worked example: why you must spread ...prev
Compare updating a field with and without the spread.
// Updating ONE field of a form object — with vs without spreading.
const form = { name: "Alice", email: "alice@dev.com", age: "30" };
// ❌ WRONG: returning just the changed field replaces the WHOLE object.
const broken = { email: "new@dev.com" };
console.log("Without spread:", broken);
// -> name and age are GONE.
// ✅ RIGHT: spread the old fields first, THEN override the one that changed.
const fixed = { ...form, email: "new@dev.com" };
console.log("With spread: ", fixed);
// ✅ Expected o
...Now you try the core move — updating one field by name while keeping the rest. Fill in the blank, then run it.
🎯 Your turn: update one field by name
Spread the old object, then override a single field.
// 🎯 YOUR TURN — managing MANY fields with ONE state object.
// A real form rarely has one input. Instead of a useState per field,
// you keep an object and update it by field name.
let form = { name: "Alice", email: "old@mail.com", age: "30" };
// handleChange takes the field's name and its new value, and returns
// a NEW object: a copy of the old one with that one field replaced.
function handleChange(field, value) {
// 👉 spread the old fields first, THEN override [field] with value
re
...3️⃣ Text, Checkbox, Select, and Radio
Most inputs bind to value, but a checkbox is the exception: it has no text, so you bind checked (a boolean) and read e.target.checked, not e.target.value. A <select> puts value on the select element itself — not on each <option> like raw HTML. Radio buttons share one name, and each one's checked is a comparison against the current state.
// Each input type binds to state a little differently.
// TEXT / EMAIL / NUMBER / PASSWORD -> use value + e.target.value
<input type="text" value={name} onChange={e => setName(e.target.value)} />
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
// CHECKBOX -> use checked + e.target.CHECKED (a true/false, not text!)
<input type="checkbox" checked={agreed}
onChange={e => setAgreed(e.target.checked)} /> I agree
// SELECT -> value goes on the <select>, NOT on each <option>
<select value={plan} onChange={e => setPlan(e.target.value)}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
// RADIO -> several inputs share one "name"; checked is a comparison
<input type="radio" name="size" value="s"
checked={size === 's'} onChange={e => setSize(e.target.value)} /> Small
<input type="radio" name="size" value="l"
checked={size === 'l'} onChange={e => setSize(e.target.value)} /> Large4️⃣ Handling Submit (preventDefault)
By default, submitting an HTML form reloads the page and throws away your React state — almost never what you want. Put onSubmit on the <form> (not on the button), and the very first line of your handler should be e.preventDefault(). After that, the form data is just an object in state — validate it, send it to an API, whatever you need.
function ContactForm() {
const [form, setForm] = useState({ name: '', message: '' });
function handleSubmit(e) {
e.preventDefault(); // STOP the browser reloading the page
console.log('Sending:', form); // now do your work: validate, fetch, etc.
}
// onSubmit lives on the <form>; the submit button triggers it.
return (
<form onSubmit={handleSubmit}>
<input value={form.name}
onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
<button type="submit">Send</button>
</form>
);
}Here's the rest of that handler in plain JS: after preventDefault, you validate, and only send the data if there are no errors. Run it to see the full submit → validate → send flow.
Worked example: the submit → validate flow
See what runs after preventDefault on a valid form.
// What your submit handler does, in plain JS (no UI needed).
const form = { email: "sam@dev.com", password: "supersecret" };
function validate(form) {
const errors = {};
if (!form.email.includes("@")) errors.email = "Invalid email";
if (form.password.length < 8) errors.password = "Too short";
return errors; // {} when everything passes
}
function handleSubmit(form) {
// In React the real first line is e.preventDefault();
const errors = validate(form);
const is
...5️⃣ Basic Validation
Validation is just a function that inspects your form object and returns an errors object — one key per broken rule, with a human-readable message. An empty {} means everything passed, which you check with Object.keys(errors).length === 0. Keep this logic in a plain function so it's easy to test and reuse. Your turn: add the password rule.
🎯 Your turn: finish the validation rules
Add an error message when the password is too short.
// 🎯 YOUR TURN — write the validation rules.
// validate() returns an "errors" object: a key for each BAD field.
// An empty {} means the form is valid.
function validate(form) {
const errors = {};
// Rule 1: name must not be blank. .trim() removes spaces.
if (!form.name.trim()) {
errors.name = "Name is required";
}
// Rule 2: password must be at least 8 characters.
// 👉 add an error to errors.password when it's too short
if (form.password.length < 8) {
___ = "Password
...Common Errors (and the fix)
- "You provided a `value` prop without an `onChange` handler" — your input has
valuebut noonChange, so React makes it read-only. AddonChange, or usereadOnlyif that's truly what you want. - Checkbox never ticks: you wrote
value={agreed}and reade.target.value. A checkbox useschecked={agreed}ande.target.checked(a boolean). - Other fields vanish when you type: you returned
{ [name]: value }without spreading. Always{ ...prev, [name]: value }so the untouched fields survive. - Page flashes / reloads on submit: you forgot
e.preventDefault()as the first line of your submit handler. - "A component is changing an uncontrolled input to be controlled": your initial state was
undefined. Start fields at''(orfalsefor checkboxes), never leave them unset.
Pro Tips
- 💡 Match each input's
nameto its state key so a singlehandleChangecan serve every field via[e.target.name]. - 💡 Validate on blur, then again on submit. Blur gives instant feedback; the submit check is your safety net.
- 💡 Only show an error once a field is "touched" — nobody likes red text before they've typed a single letter.
- 💡 For big forms, reach for React Hook Form. It cuts boilerplate and re-renders far fewer times than manual state.
📋 Quick Reference
| Input | React pattern |
|---|---|
| Text / Email | value={v} onChange={e => set(e.target.value)} |
| Checkbox | checked={v} onChange={e => set(e.target.checked)} |
| Select | <select value={v} onChange={fn}> |
| Radio | checked={v === 'x'} onChange={e => set(e.target.value)} |
| Many fields | setForm(p => ({ ...p, [name]: value })) |
| Submit | <form onSubmit={fn}> |
| Stop reload | e.preventDefault() |
| Is it valid? | Object.keys(errors).length === 0 |
Frequently Asked Questions
Q: What exactly is the difference between controlled and uncontrolled?
A controlled input's value comes from React state (value={x}), so state is the single source of truth. An uncontrolled input keeps its value in the DOM, and you read it with a ref only when needed. Controlled is the default React style and the one you should learn first.
Q: Why must I spread ...prev when updating one field?
Setting state to { [name]: value } replaces the whole object, so every other field becomes undefined. Spreading copies the existing fields first, then your one change overrides just that key.
Q: Why use e.target.checked for checkboxes?
A checkbox represents a yes/no, not text. Its value attribute doesn't change when you tick it, but checked flips between true and false — that's the boolean you want in state.
Q: Do I still need validation if I add HTML required?
HTML attributes help, but they're easy to bypass and can't express rules like "passwords must match." Always validate in JavaScript too — and remember client-side checks are for UX; the server must validate again for security.
Mini-Challenge: Signup Form Logic
No blanks this time — just a brief and an outline. Build the state logic behind a signup form: a field-updater that keeps the other fields, and a validator for email, password, and the terms checkbox. Run it and check your output against the expected line in the comments.
🎯 Mini-Challenge: build the signup state
Write the field-updater and the validator yourself.
// 🎯 MINI-CHALLENGE: signup form STATE logic (no UI needed).
// You're building the brain of a signup form, step by step.
//
// 1. Start with a form object: { email: '', password: '', terms: false }
// 2. Write handleField(form, field, value) that returns a NEW form
// with ONE field changed (hint: spread ...form, then [field]: value)
// 3. Write validate(form) returning an errors object:
// - email needs an "@" -> errors.email = "Invalid email"
// - password length <
...🎉 Lesson Complete!
- ✅ A controlled input binds
value+onChangeto state - ✅ One state object + computed key
[name]scales to many fields - ✅ Always spread
...prevbefore overriding a field - ✅ Checkboxes use
checked/e.target.checked, notvalue - ✅ Start submit handlers with
e.preventDefault() - ✅ Validation returns an errors object; empty means valid
- ✅ Next lesson: React Router — turn your forms into multi-page apps
Sign up for free to track which lessons you've completed and get learning reminders.