Forms and Controlled Components
Reviewed & published by Brayan K
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.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
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.
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.
// 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(eventValue);
// 3) React re-renders -> the input's value={state} now shows it
console.log(`typed "${key}" -> input now shows "${state}"`);
}
console.log("Final value in state:", state);
// ✅ Expected output:
// typed "H" -> input now shows "H"
// typed "i" -> input now shows "Hi"
// Final value in state: Hi2️⃣ 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.
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.
// Updating ONE field of a form object — with vs without spreading.
const form = { name: "Alice", email: "[email protected]", age: "30" };
// ❌ WRONG: returning just the changed field replaces the WHOLE object.
const broken = { email: "[email protected]" };
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: "[email protected]" };
console.log("With spread: ", fixed);
// ✅ Expected output:
// Without spread: { email: '[email protected]' }
// With spread: { name: 'Alice', email: '[email protected]', age: '30' }Now you try the core move — updating one field by name while keeping the rest. Fill in the blank, then run it.
// 🎯 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: "[email protected]", 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
return { ___, [field]: value }; // 👉 replace ___ with ...form
}
form = handleChange("email", "[email protected]");
console.log(form);
// ✅ Expected output:
// { name: 'Alice', email: '[email protected]', age: '30' }
//
// Note: name and age are untouched because you spread ...form first.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.
4️⃣ 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.
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.
// What your submit handler does, in plain JS (no UI needed).
const form = { email: "[email protected]", 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 isValid = Object.keys(errors).length === 0; // {} -> length 0 -> valid
if (!isValid) {
console.log("Blocked. Errors:", errors);
return;
}
console.log("Submitting:", form); // safe to send to an API now
}
handleSubmit(form);
// ✅ Expected output:
// Submitting: { email: '[email protected]', password: 'supersecret' }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 — 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 must be at least 8 characters"; // 👉 errors.password
}
return errors;
}
console.log(validate({ name: "", password: "abc" }));
console.log(validate({ name: "Sam", password: "supersecret" }));
// ✅ Expected output:
// { name: 'Name is required', password: 'Password must be at least 8 characters' }
// {}Common Errors (and the fix)
- "You provided a prop without an handler" — your input has value but no onChange, so React makes it read-only. Add onChange, or use readOnly if that's truly what you want.
- Checkbox never ticks: you wrote value={agreed} and read e.target.value. A checkbox uses checked={agreed} and e.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 '' (or false for checkboxes), never leave them unset.
Pro Tips
- 💡 Match each input's name to its state key so a single handleChange can 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: 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 < 8 -> errors.password = "Too short"
// - terms must be true (checkbox) -> errors.terms = "You must agree"
// 4. Fill the form so it's valid, then log validate(form) -> should be {}
//
// ✅ Expected (when all three fields are good): {}
// your code here🎉 Lesson Complete!
- ✅ A controlled input binds value + onChange to state
- ✅ One state object + computed key [name] scales to many fields
- ✅ Always spread ...prev before overriding a field
- ✅ Checkboxes use checked / e.target.checked, not value
- ✅ Start submit handlers with e.preventDefault()
- ✅ Validation returns an errors object; empty means valid
Practice quiz
What two props make a text input controlled?
- defaultValue and onInput
- checked and onChange
- value and onChange
- ref and onSubmit
Answer: value and onChange. A controlled input binds value={state} and updates state in onChange on every keystroke.
When updating one field of a form state object, why must you spread ...prev first?
- Because returning { [name]: value } replaces the whole object and loses the other fields
- For performance
- Because React requires it syntactically
- To avoid a key warning
Answer: Because returning { [name]: value } replaces the whole object and loses the other fields. Setting state to { [name]: value } replaces the whole object; spread the old fields first to keep them.
What is a 'computed key' in { [name]: value }?
- A key React computes automatically
- A memoized value
- A unique list key
- The square brackets use the value of the variable name as the object key
Answer: The square brackets use the value of the variable name as the object key. [name] means 'use the value of name as the key', so one handler can update whichever field changed.
Which property do you bind and read for a checkbox?
- value and e.target.value
- checked and e.target.checked
- selected and e.target.selected
- on and e.target.on
Answer: checked and e.target.checked. A checkbox is yes/no, so bind checked={bool} and read e.target.checked (a boolean), not value.
For a <select>, where does the value prop go?
- On the <select> element itself
- On each <option>
- On the surrounding <form>
- On a hidden input
Answer: On the <select> element itself. In React, value goes on the <select> element, unlike raw HTML where the selected option is marked.
What is the first line of a typical onSubmit handler?
- return false
- e.stopPropagation()
- e.preventDefault()
- console.log(form)
Answer: e.preventDefault(). Call e.preventDefault() first to stop the browser reloading the page and wiping React state.
How should a validation function report problems?
- By throwing an error
- By returning an errors object — one key per broken rule, empty {} means valid
- By calling alert()
- By setting a global flag
Answer: By returning an errors object — one key per broken rule, empty {} means valid. Return an errors object; check Object.keys(errors).length === 0 to know the form is valid.
What causes React's 'value prop without an onChange handler' warning?
- Using defaultValue
- Too many fields
- Using a select
- An input has value but no onChange, making it read-only
Answer: An input has value but no onChange, making it read-only. A controlled input needs both value and onChange; with value alone React makes it read-only and warns.
Why initialize each form field to '' (or false) rather than leaving it undefined?
- It looks cleaner
- To avoid the 'changing an uncontrolled input to be controlled' warning
- It is required by JSX
- To speed up validation
Answer: To avoid the 'changing an uncontrolled input to be controlled' warning. Starting at undefined then becoming defined flips the input's mode; initialize to a defined value.
How do you check that an errors object means the form is valid?
- errors === null
- errors === false
- Object.keys(errors).length === 0
- !errors
Answer: Object.keys(errors).length === 0. An empty {} has zero keys, so Object.keys(errors).length === 0 indicates no validation errors.
Continue this course
- Previous: Lifting State Up
- Next: Controlled vs Uncontrolled Components — The two ways React handles form inputs — and when to use each
- Quick reference: React cheat sheet › State & Events