π Forms in React
Forms are where users hand your app the data it exists to work with β a login, a search, a checkout, a comment. In React, the trick is to make component state the single source of truth for every field. Learn that one loop and every form, from a one-line search box to a multi-step wizard, follows the same shape.
Week 4 · Day 3 (Wednesday: Handling Events in React) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the difference between controlled and uncontrolled components
- Wire the
value/onChangeloop that makes state the single source of truth - Manage many inputs with one handler using the
nameattribute - Handle every input type: text, textarea, select, checkbox, radio, and file
- Add client-side validation with clear, real-time feedback
- Extract form logic into a reusable custom
useFormhook
Estimated Time: 70 minutes
Practice: Build a validated registration form and a reusable form hook.
In This Lesson
Controlled vs Uncontrolled
An HTML input keeps its own value inside the DOM β you type, the browser remembers. React offers two ways to work with that:
- A controlled component hands the value to React state. React holds the truth; the input just displays it. This is the default you'll reach for.
- An uncontrolled component lets the DOM keep the value, and you read it with a ref only when you need it β handy for simple or file inputs.
π‘ Which should I use?
Default to controlled. Having the value in state means you can validate as the user types, disable the submit button until the form is valid, transform input, and drive other UI from it. Reach for uncontrolled only for the occasional simple case (a lone file picker, integrating a non-React widget).
The Controlled Input Loop
A controlled input is a two-way binding built from two props: value (state flows into the input) and onChange (keystrokes flow back into state). This closed loop is the heart of every React form.
value; the input's onChange sets the state. Each keystroke completes the loop and re-renders.import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
// Every keystroke pushes the new text back into state.
const handleChange = (e) => setValue(e.target.value);
return (
<div>
<input
type="text"
value={value} {/* state flows IN */}
onChange={handleChange} {/* changes flow OUT */}
placeholder="Type somethingβ¦"
/>
<p>You typed: {value}</p>
</div>
);
}
β οΈ value without onChange = a read-only input
If you set value={something} but forget onChange, React locks the field β typing does nothing, and you'll see a console warning. A controlled input needs both halves of the loop. (For an intentionally read-only field, add readOnly.)
Many Inputs, One Handler
A real form has several fields. You don't need a separate handler for each β store the fields in one state object and let a single handler use the input's name to update the right key. The computed-property syntax [name]: value makes it clean.
function RegistrationForm() {
const [form, setForm] = useState({
username: '',
email: '',
password: '',
});
// One handler for the whole form, keyed by each input's name.
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitting:', form);
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input id="username" name="username"
value={form.username} onChange={handleChange} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email"
value={form.email} onChange={handleChange} />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password"
value={form.password} onChange={handleChange} />
<button type="submit">Register</button>
</form>
);
}
β Why the spread and updater form?
setForm((prev) => ({ ...prev, [name]: value })) copies the existing fields and overwrites just one. The prev => updater guarantees you're merging into the latest state, not a stale snapshot β important when changes come quickly.
Every Input Type
The controlled pattern adapts to each kind of input. The only thing that changes is which property you bind and read.
| Input | Bind to | Read from onChange |
|---|---|---|
| text, textarea, select, radio | value | e.target.value |
| checkbox | checked | e.target.checked |
| multiple select | value (array) | e.target.selectedOptions |
| file | β (uncontrolled) | e.target.files |
Textarea & select
function CommentAndPick() {
const [comment, setComment] = useState('');
const [fruit, setFruit] = useState('');
return (
<>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
/>
<p>{comment.length} characters</p>
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
<option value="">Chooseβ¦</option>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</select>
</>
);
}
Note that in React a <textarea> uses value (not children), and a <select> uses value on the select element itself rather than a selected attribute on an option β more consistent than plain HTML.
Checkboxes β bind checked, not value
function Preferences() {
const [prefs, setPrefs] = useState({ newsletter: false, notifications: false });
const handleCheck = (e) => {
const { name, checked } = e.target; // read checked for checkboxes
setPrefs((prev) => ({ ...prev, [name]: checked }));
};
return (
<>
<label>
<input type="checkbox" name="newsletter"
checked={prefs.newsletter} onChange={handleCheck} /> Newsletter
</label>
<label>
<input type="checkbox" name="notifications"
checked={prefs.notifications} onChange={handleCheck} /> Notifications
</label>
</>
);
}
Radio buttons β share a name, compare the value
function PlanChooser() {
const [plan, setPlan] = useState('free');
return ['free', 'pro', 'team'].map((option) => (
<label key={option}>
<input
type="radio"
value={option}
checked={plan === option} {/* the selected one matches state */}
onChange={(e) => setPlan(e.target.value)}
/> {option}
</label>
));
}
File input β read e.target.files
function Uploader() {
const [file, setFile] = useState(null);
const handleFile = (e) => {
const chosen = e.target.files[0]; // FileList β first file
setFile(chosen);
};
return (
<>
<input type="file" accept="image/*" onChange={handleFile} />
{file && <p>{file.name} β {(file.size / 1024).toFixed(1)} KB</p>}
</>
);
}
π‘ File inputs are always uncontrolled
For security reasons a browser won't let JavaScript set a file input's value, so you can't make it fully controlled. Read the selected files in onChange and store what you need (the File object, a preview URL) in state.
Validation & Feedback
Because state already holds every value, validating is just deriving errors from that state. Store an errors object, check on submit, and clear each error as the user fixes it.
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const next = {};
if (!form.email) next.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(form.email)) next.email = 'Email is invalid';
if (!form.password) next.password = 'Password is required';
else if (form.password.length < 6) next.password = 'At least 6 characters';
setErrors(next);
return Object.keys(next).length === 0; // valid when no errors
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
// Clear this field's error as soon as the user edits it.
if (errors[name]) setErrors((prev) => ({ ...prev, [name]: '' }));
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) console.log('Valid! Submitting', form);
};
return (
<form onSubmit={handleSubmit} noValidate>
<input name="email" value={form.email} onChange={handleChange}
aria-invalid={!!errors.email} />
{errors.email && <span className="error">{errors.email}</span>}
<input name="password" type="password"
value={form.password} onChange={handleChange}
aria-invalid={!!errors.password} />
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Log in</button>
</form>
);
}
β Client-side is convenience, not security
Validating in the browser gives instant feedback and a nicer experience, but a determined user can bypass it. Always validate again on the server before trusting or storing anything. Treat the two as partners, not alternatives.
Uncontrolled Components
Sometimes you don't need React to track every keystroke β you just want the values when the form submits. An uncontrolled form lets the DOM hold the data and reads it with a ref, using defaultValue for initial content:
import { useRef } from 'react';
function QuickForm() {
const formRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// Pull all fields at once via the native FormData API.
const data = Object.fromEntries(new FormData(formRef.current));
console.log(data); // { name: 'β¦', email: 'β¦' }
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="name" defaultValue="Ada" />
<input name="email" type="email" defaultValue="ada@example.com" />
<button type="submit">Submit</button>
</form>
);
}
β οΈ value vs defaultValue
Use value for controlled inputs (React owns it) and defaultValue for uncontrolled (the DOM owns it, this just seeds the starting text). Passing both, or switching a field from one to the other mid-life, triggers React's "controlled/uncontrolled" warning.
A Reusable useForm Hook
Once you've written a few forms, the pattern repeats: values, errors, a change handler, a submit handler. Extract it into a custom hook and every future form gets shorter.
function useForm(initialValues, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setValues((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
// handleSubmit takes YOUR onSubmit and wraps it with validation.
const handleSubmit = (onSubmit) => async (e) => {
e.preventDefault();
const found = validate ? validate(values) : {};
setErrors(found);
if (Object.keys(found).length === 0) {
setSubmitting(true);
await onSubmit(values);
setSubmitting(false);
}
};
const reset = () => { setValues(initialValues); setErrors({}); };
return { values, errors, submitting, handleChange, handleSubmit, reset };
}
Using it, a login form collapses to almost nothing:
function Login() {
const validate = (v) => {
const e = {};
if (!v.email) e.email = 'Required';
if (!v.password) e.password = 'Required';
return e;
};
const { values, errors, submitting, handleChange, handleSubmit } =
useForm({ email: '', password: '' }, validate);
const onSubmit = async (v) => {
await fakeApiLogin(v);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input name="email" value={values.email} onChange={handleChange} />
{errors.email && <span className="error">{errors.email}</span>}
<input name="password" type="password"
value={values.password} onChange={handleChange} />
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit" disabled={submitting}>
{submitting ? 'Logging inβ¦' : 'Log in'}
</button>
</form>
);
}
π When to reach for a library
Your useForm is perfect for learning and for small-to-medium forms. For big forms with complex validation, async checks, and performance concerns, mature libraries like React Hook Form handle the edge cases and minimize re-renders. Learn the manual pattern first β then you'll know exactly what those libraries do for you.
Practice & Quiz
ποΈ Exercise 1: A live character counter
Goal: Build a controlled <textarea> for a tweet that shows characters remaining out of 280 and disables the Post button when the limit is exceeded.
function TweetBox() {
const [text, setText] = useState('');
const MAX = 280;
// TODO: bind value/onChange, compute remaining, disable when over limit
}
π‘ Hint
Remaining is MAX - text.length. Disable with disabled={text.length > MAX}. Because state holds the text, both the counter and the button derive straight from it.
β Solution
function TweetBox() {
const [text, setText] = useState('');
const MAX = 280;
const remaining = MAX - text.length;
return (
<div>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<p style={{ color: remaining < 0 ? 'crimson' : 'inherit' }}>
{remaining} left
</p>
<button disabled={remaining < 0 || text.length === 0}>Post</button>
</div>
);
}
ποΈ Exercise 2: Password match validation
Goal: Two password fields (password and confirm) in one state object. Show "Passwords don't match" only when both are non-empty and differ.
π‘ Hint
Keep both in one object with a shared handleChange keyed by name. Derive the error during render: form.confirm && form.password !== form.confirm.
β Solution
function PasswordPair() {
const [form, setForm] = useState({ password: '', confirm: '' });
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const mismatch = form.confirm && form.password !== form.confirm;
return (
<>
<input name="password" type="password"
value={form.password} onChange={handleChange} />
<input name="confirm" type="password"
value={form.confirm} onChange={handleChange} />
{mismatch && <span className="error">Passwords don't match</span>}
</>
);
}
π― Quick Quiz
Question 1: What two props make an input a controlled component?
Question 2: For a checkbox, which property do you bind and read?
Question 3: Where must you also validate, even with perfect client-side checks?
Best Practices & Pitfalls
β Do
- Default to controlled components so state is the single source of truth
- Use the
nameattribute +[name]: valueto share one handler across fields - Bind
checkedfor checkboxes/radios andvaluefor everything text-like - Give inputs
<label>s (viahtmlFor/id) and validate on both client and server
β Don't
- Set
valuewithoutonChangeβ the field becomes accidentally read-only - Mutate the state object directly; always spread into a new object
- Mix
valueanddefaultValueon the same input - Trust client-side validation as your only defense
β οΈ The controlled/uncontrolled warning
// β Starts undefined, becomes controlled after first keystroke β warning
const [name, setName] = useState();
// β
Initialize to an empty string so it's controlled from the start
const [name, setName] = useState('');
Initialize controlled string inputs to '', not undefined. Otherwise React sees the field switch from uncontrolled to controlled and warns you.
Summary
π Key Takeaways
- Controlled components make React state the single source of truth via the
value/onChangeloop - Manage many fields with one state object and one handler keyed by
name - Each input type binds a specific property:
valuefor text/select/radio,checkedfor checkboxes,filesfor uploads - Validation is just deriving errors from state β instant feedback in the browser, always re-checked on the server
- Uncontrolled inputs (refs +
defaultValue) suit simple or file cases - A custom
useFormhook packages the pattern so every future form is shorter
π Additional Resources
- React docs β <input> component reference
- React docs β Reacting to input with state
- React Hook Form β a popular forms library
π What's Next?
Your forms produce data β often lists of it. Next up: Rendering Lists with map() β turning arrays into UI, the all-important key prop, and rendering collections efficiently.
π Forms conquered!
You can now collect, validate, and manage any user input the controlled way β the backbone of every real app.