π Form Handling in React
Forms are where your app listens to its users β logins, sign-ups, checkouts, comments. In React you usually make each input a "controlled" field so state and screen stay perfectly in sync. This lesson takes you from a single text box to a validated, multi-field registration form.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Build a controlled input and explain why React becomes the single source of truth
- Handle text, textarea, checkbox, radio, and select elements the React way
- Manage many fields with a single state object and one
handleChange - Validate input with both HTML5 attributes and custom logic, showing errors at the right time
- Decide when a form library like React Hook Form earns its place
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a sign-up form with live validation and a disabled-until-valid submit button.
In This Lesson
Forms in React
In plain HTML, each form element quietly keeps its own value inside the DOM. React usually flips that around: you store the value in state and feed it back into the input. The result is that your component always knows, at any instant, exactly what's in every field.
π‘ Analogy: A paper form is filled out in private and handed over at the end. A React controlled form is more like a live interview: the app "hears" every answer as it's typed, can react instantly, and can even change later questions based on earlier ones.
Because state drives the input, you get live validation, conditional fields, formatting-as-you-type, and a disabled-until-valid submit button β all for free.
Controlled Inputs
A controlled component is a form element whose value comes from state and whose onChange writes back to that state. Two lines wire up the loop:
import { useState } from 'react';
function NameInput() {
const [name, setName] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
alert(`Hello, ${name}!`);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
type="text"
value={name} {/* value comes FROM state */}
onChange={(e) => setName(e.target.value)} {/* changes go BACK to state */}
/>
</label>
<button type="submit">Greet me</button>
</form>
);
}
nameholds the current value.- The input's
valueattribute is bound toname. onChangeupdates state on every keystroke, which re-renders the input.onSubmitreads the state and callspreventDefault()to stop the page reloading.
β οΈ Controlled means never undefined
Always initialize with a defined value like useState(''). If value is undefined, React treats the input as uncontrolled and later warns when it "switches to controlled." Start with an empty string, not null or nothing.
Every Form Element
Text inputs bind to value, but checkboxes and radios bind to checked, and multi-selects work with arrays. Here's the whole toolkit.
Text and textarea
Unlike HTML β where a <textarea> holds its text between the tags β React's <textarea> uses a value prop, so it behaves exactly like a text input:
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
/>
Checkbox and radio
These use checked (a boolean), not value:
function Preferences() {
const [subscribed, setSubscribed] = useState(false);
const [plan, setPlan] = useState('free');
return (
<form>
<label>
<input
type="checkbox"
checked={subscribed}
onChange={(e) => setSubscribed(e.target.checked)}
/>
Subscribe to the newsletter
</label>
{['free', 'pro', 'enterprise'].map((p) => (
<label key={p}>
<input
type="radio"
name="plan"
value={p}
checked={plan === p}
onChange={(e) => setPlan(e.target.value)}
/>
{p}
</label>
))}
</form>
);
}
π value vs. checked
Text, textarea, select: control with the value prop.
Checkbox, radio: control with the checked prop, and read e.target.checked in the handler.
Select dropdowns
React puts value on the <select> itself rather than a selected attribute on an option:
<select value={country} onChange={(e) => setCountry(e.target.value)}>
<option value="">-- Select a country --</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
</select>
For a multi-select, value is an array and you gather the chosen options:
const handleSkillsChange = (e) => {
const chosen = Array.from(e.target.selectedOptions, (opt) => opt.value);
setSkills(chosen);
};
<select multiple value={skills} onChange={handleSkillsChange}>
<option value="react">React</option>
<option value="node">Node.js</option>
<option value="python">Python</option>
</select>
Many Fields, One State Object
Declaring a separate useState for every field gets tedious fast. A cleaner pattern stores the whole form in one object and uses a single handler that keys off each input's name attribute:
import { useState } from 'react';
function RegistrationForm() {
const [form, setForm] = useState({
firstName: '',
email: '',
age: '',
interests: [],
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
if (type === 'checkbox') {
// Add or remove the value from the interests array
setForm((prev) => ({
...prev,
interests: checked
? [...prev.interests, value]
: prev.interests.filter((i) => i !== value),
}));
} else {
setForm((prev) => ({ ...prev, [name]: value }));
}
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitting', form);
};
return (
<form onSubmit={handleSubmit}>
<input name="firstName" value={form.firstName} onChange={handleChange} />
<input name="email" type="email" value={form.email} onChange={handleChange} />
<input name="age" type="number" value={form.age} onChange={handleChange} />
<label>
<input type="checkbox" name="interests" value="tech"
checked={form.interests.includes('tech')} onChange={handleChange} />
Technology
</label>
<button type="submit">Register</button>
</form>
);
}
β
The trick: name matches the state key
The computed-property syntax [name]: value updates whichever field fired the change. As long as each input's name matches a key in your state object, one handler serves the entire form.
β οΈ Use the functional update form
Prefer setForm(prev => ({ ...prev, ... })) over setForm({ ...form, ... }). When several updates happen close together, reading prev guarantees you're spreading the latest state, not a stale snapshot.
Validation
Good validation catches mistakes early without nagging users before they've had a chance to type. Two layers work together.
Layer 1 β HTML5 attributes
The browser can enforce simple rules for free with required, minLength, type="email", pattern, and friends:
<input
type="text"
required
minLength={3}
maxLength={20}
pattern="[A-Za-z0-9]+"
title="Letters and numbers only"
/>
<input type="email" required />
<input type="number" min={18} max={120} required />
This is great as a baseline, but the UI is browser-specific and hard to style. For a consistent, friendly experience, add a custom layer.
Layer 2 β Custom validation with "touched" tracking
The key idea: only show a field's error once the user has left that field (its onBlur fired), or once they try to submit. That avoids yelling "Email is required" the instant the form appears.
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const validate = (values) => {
const next = {};
if (!values.email) next.email = 'Email is required';
else if (!/^\S+@\S+\.\S+$/.test(values.email)) next.email = 'Enter a valid email';
if (!values.password) next.password = 'Password is required';
else if (values.password.length < 8) next.password = 'At least 8 characters';
return next;
};
const handleChange = (e) => {
const { name, value } = e.target;
const nextForm = { ...form, [name]: value };
setForm(nextForm);
setErrors(validate(nextForm)); // keep errors fresh as they type
};
const handleBlur = (e) => {
setTouched((prev) => ({ ...prev, [e.target.name]: true }));
};
const handleSubmit = (e) => {
e.preventDefault();
const found = validate(form);
setErrors(found);
setTouched({ email: true, password: true }); // reveal all errors
if (Object.keys(found).length === 0) {
console.log('Valid! Submitting', form);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<input name="email" value={form.email}
onChange={handleChange} onBlur={handleBlur} />
{touched.email && errors.email && <p className="error">{errors.email}</p>}
<input name="password" type="password" value={form.password}
onChange={handleChange} onBlur={handleBlur} />
{touched.password && errors.password && <p className="error">{errors.password}</p>}
<button type="submit">Sign up</button>
</form>
);
}
π‘ noValidate hands you the wheel
Adding noValidate to the <form> turns off the browser's built-in popups so your custom messages show instead. You still keep the HTML5 attributes as documentation and as a fallback.
β οΈ Client validation is never enough
Everything above improves the experience, but it runs in the user's browser and can be bypassed. Always re-validate on the server before trusting or storing any data.
Form Libraries
Hand-rolled state and validation are perfect for learning and for small forms. Once forms get large β dozens of fields, cross-field rules, async checks β a library removes boilerplate and improves performance. React Hook Form is the current go-to: small, fast, and built around uncontrolled inputs under the hood.
import { useForm } from 'react-hook-form';
function HookForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm();
const password = watch('password');
const onSubmit = async (data) => {
await new Promise((r) => setTimeout(r, 800)); // pretend API call
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', {
required: 'Email is required',
pattern: { value: /^\S+@\S+\.\S+$/, message: 'Invalid email' },
})} />
{errors.email && <p className="error">{errors.email.message}</p>}
<input type="password" {...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'At least 8 characters' },
})} />
{errors.password && <p className="error">{errors.password.message}</p>}
<input type="password" {...register('confirm', {
validate: (v) => v === password || 'Passwords do not match',
})} />
{errors.confirm && <p className="error">{errors.confirm.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submittingβ¦' : 'Submit'}
</button>
</form>
);
}
| Approach | Best for | Trade-off |
|---|---|---|
| Manual (useState) | Small forms, learning the fundamentals | Boilerplate grows with field count |
| React Hook Form | Most real-world forms; performance-sensitive UIs | A new API to learn |
| Formik | Teams already standardized on it | Larger, more re-renders than RHF |
You'll often pair either library with a schema validator like Zod or Yup to describe your rules declaratively.
Hands-on Exercise
ποΈ Build a Validated Sign-up Form
Objective: Put controlled inputs, single-object state, and touched-based validation together.
Requirements:
- Fields:
username,email,password, and a "I accept the terms" checkbox. - Store everything in one state object with one
handleChange. - Show each error only after the field is touched or the user submits.
- Disable the submit button until the form is valid and the terms are accepted.
π‘ Hint
Compute const errors = validate(form) on each render and derive const isValid = Object.keys(errors).length === 0 && form.acceptedTerms. Bind the button's disabled={!isValid}. Remember the checkbox reads e.target.checked, not value.
β Sample solution
import { useState } from 'react';
function SignUp() {
const [form, setForm] = useState({
username: '', email: '', password: '', acceptedTerms: false,
});
const [touched, setTouched] = useState({});
const validate = (v) => {
const e = {};
if (v.username.length < 3) e.username = 'At least 3 characters';
if (!/^\S+@\S+\.\S+$/.test(v.email)) e.email = 'Enter a valid email';
if (v.password.length < 8) e.password = 'At least 8 characters';
return e;
};
const errors = validate(form);
const isValid = Object.keys(errors).length === 0 && form.acceptedTerms;
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
};
const handleBlur = (e) =>
setTouched((prev) => ({ ...prev, [e.target.name]: true }));
const handleSubmit = (e) => {
e.preventDefault();
setTouched({ username: true, email: true, password: true });
if (isValid) console.log('Account created', form);
};
const show = (field) => touched[field] && errors[field];
return (
<form onSubmit={handleSubmit} noValidate>
<input name="username" value={form.username}
onChange={handleChange} onBlur={handleBlur} placeholder="Username" />
{show('username') && <p className="error">{errors.username}</p>}
<input name="email" value={form.email}
onChange={handleChange} onBlur={handleBlur} placeholder="Email" />
{show('email') && <p className="error">{errors.email}</p>}
<input name="password" type="password" value={form.password}
onChange={handleChange} onBlur={handleBlur} placeholder="Password" />
{show('password') && <p className="error">{errors.password}</p>}
<label>
<input type="checkbox" name="acceptedTerms"
checked={form.acceptedTerms} onChange={handleChange} />
I accept the terms
</label>
<button type="submit" disabled={!isValid}>Create account</button>
</form>
);
}
π― Quick Quiz
Question 1: What makes an input a controlled component in React?
Question 2: With the single-object state pattern, what lets one handleChange update the right field?
Question 3: Why should you re-validate form data on the server even after client-side validation?
Best Practices
| β Do | β Avoid |
|---|---|
Initialize state with defined values ('', false, []) | Leaving value as undefined (uncontrolled warning) |
Bind checkboxes/radios with checked | Using value for checkbox state |
| Show errors after blur or submit (touched) | Flashing errors before the user types |
Pair a <label htmlFor> with each input | Unlabeled fields (bad accessibility) |
| Re-validate on the server | Trusting client-side checks alone |
β Accessibility counts
Associate every input with a label, and tie error text to its field with aria-describedby. Screen-reader users then hear the field name and the error together β the same information sighted users get from the red message underneath.
Summary & Quiz
π Key Takeaways
- Controlled inputs bind
valueto state and update it viaonChange, making React the single source of truth. - Text/textarea/select use
value; checkboxes and radios usechecked; multi-selects use arrays. - Store many fields in one state object and key updates off the input's
name. - Combine HTML5 attributes with custom validation, revealing errors only after a field is touched or submitted.
- Reach for React Hook Form as forms grow β and always re-validate on the server.
π Further Reading
π What's Next?
We kept saying "controlled." Next we compare it head-to-head with the alternative β controlled vs. uncontrolled components β so you know exactly when a ref beats a piece of state.
π Form mastered!
You can now capture, validate, and submit user input with confidence β the backbone of almost every real app.