π Form Handling in React
Forms are where users hand your app their data β logins, checkouts, sign-ups. Get them wrong and users bounce; get them right and they feel effortless. This lesson covers React's two form models, a scalable change-handling pattern, validation that respects the user, and accessible, async-ready submission.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish controlled and uncontrolled components and pick the right one
- Manage a multi-field form with one object of state and a single generic change handler
- Choose a validation timing (on change / on blur / on submit) that fits the situation
- Build accessible forms with proper labels,
aria-invalid, and live error messages - Handle async submission with loading, success, and error states β and know when to reach for React Hook Form
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a validated sign-up form with per-field errors and a submitting state.
In This Lesson
Two Ways to Handle Forms
In plain HTML, a form owns its own state β the browser tracks what's typed, and a submit sends it off. React can either take that state over (a controlled component) or let the DOM keep it and read it only when needed (an uncontrolled component). Almost all interactive React forms are controlled, because state-driven UI is what React does best.
Controlled vs. Uncontrolled
Controlled: React is the single source of truth
The input's value comes from state, and every keystroke calls onChange to update that state. The state and the input can never disagree.
import { useState } from 'react';
function ControlledInput() {
const [name, setName] = useState('');
return (
<label>
Name:
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
);
}
π Analogy
A controlled input is a puppet β it moves only when React pulls the strings, so you have total, moment-to-moment control. An uncontrolled input is a rental car: you don't watch the drive, you just ask where it ended up when the keys come back.
Uncontrolled: the DOM keeps the value
You attach a ref and read .value only at submit time. Less code, but no live validation. It's the right tool for simple forms and the one case controlled inputs can't cover: <input type="file">, whose value is read-only.
import { useRef } from 'react';
function UncontrolledInput() {
const nameRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
alert(`Hello, ${nameRef.current.value}!`);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" defaultValue="" ref={nameRef} />
<button type="submit">Submit</button>
</form>
);
}
| Aspect | Controlled | Uncontrolled |
|---|---|---|
| Source of truth | React state | The DOM |
| Live validation | Easy, immediate | Awkward, usually at submit |
| Current value | Always in state | Read on demand via ref |
| Code volume | More | Less |
| Best for | Most interactive forms | Simple forms, file inputs |
One State, One Handler
Giving every field its own useState gets unwieldy fast. The scalable pattern holds the whole form in one state object and uses a single change handler that keys off each input's name attribute.
import { useState } from 'react';
function ProfileForm() {
const [form, setForm] = useState({
username: '',
email: '',
role: 'user',
subscribe: false,
});
// One handler for text, select, and checkbox inputs.
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
return (
<form>
<input name="username" value={form.username} onChange={handleChange} />
<input name="email" type="email" value={form.email} onChange={handleChange} />
<select name="role" value={form.role} onChange={handleChange}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<label>
<input
name="subscribe"
type="checkbox"
checked={form.subscribe}
onChange={handleChange}
/>
Subscribe to the newsletter
</label>
</form>
);
}
π‘ Two things make this work
First, the computed key [name] updates whichever field matches the input's name. Second, the updater form setForm((prev) => β¦) guarantees you merge into the latest state, not a stale snapshot. Add a new field and the handler needs zero changes.
Validation Timing
Validation is less about the rules and more about when you show them. Fire too early and you scold users mid-type; fire too late and they're surprised at submit. The sweet spot for most forms is validate on blur, then re-validate on change once a field has been touched.
| Timing | Feel | Trade-off |
|---|---|---|
| On change | Instant feedback | Can nag while the user is still typing |
| On blur | Polite β waits until they leave a field | Slightly delayed feedback |
| On submit | Never interrupts | Errors arrive all at once, late |
A clean way to structure this: keep a validate function that returns an errors object, track which fields are touched, and only display an error when its field is both touched and invalid.
function validate(values) {
const errors = {};
if (!values.email) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
errors.email = 'Enter a valid email address';
}
if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
return errors;
}
β οΈ Client validation is UX, not security
Client-side checks make the form pleasant, but a user can bypass them entirely. Always re-validate on the server before trusting or storing any submitted data. Never treat a passing client form as proof the data is safe.
Accessible Forms
An inaccessible form locks out screen-reader and keyboard users β and often fails legal requirements. The good news: accessibility is mostly a few consistent habits.
- Pair every control with a
<label htmlFor>that matches the input'sid. - Mark invalid fields with
aria-invalid="true". - Link the error text to the field with
aria-describedby. - Give the error container
role="alert"so it's announced the moment it appears. - On failed submit, move focus to the first invalid field.
function EmailField({ value, error, onChange }) {
const invalid = Boolean(error);
return (
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
aria-invalid={invalid}
aria-describedby={invalid ? 'email-error' : undefined}
/>
{invalid && (
<div id="email-error" role="alert" className="error-message">
{error}
</div>
)}
</div>
);
}
β Accessible by default
Because the <label> is tied to the input, clicking the label focuses the field, and screen readers announce "Email, edit text." When an error appears, role="alert" reads it aloud immediately. That's a better experience for everyone, not only assistive-tech users.
Async Submission States
Submitting almost always means an API call, which takes time and can fail. Track a status so the UI can disable the button, show a spinner label, and surface errors β never leaving the user wondering whether their click registered.
import { useState } from 'react';
function SubscribeForm() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState('idle'); // idle | submitting | success | error
const [errorMsg, setErrorMsg] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('submitting');
setErrorMsg('');
try {
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error('Subscription failed. Please try again.');
setStatus('success');
setEmail('');
} catch (err) {
setErrorMsg(err.message);
setStatus('error');
}
};
if (status === 'success') {
return <p role="status">π You're subscribed!</p>;
}
return (
<form onSubmit={handleSubmit}>
{status === 'error' && (
<div role="alert" className="error-message">{errorMsg}</div>
)}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Subscribingβ¦' : 'Subscribe'}
</button>
</form>
);
}
β οΈ Always disable the submit button while submitting
Without disabled={status === 'submitting'}, an impatient user double-clicks and you fire two requests β creating duplicate records. Disabling the button during the request is the simplest guard against this classic bug.
When to Use a Library
Hand-rolling forms teaches you the fundamentals, but real projects with many fields, cross-field rules, and complex validation lean on a library. The modern standard is React Hook Form, often paired with a schema validator like Zod. It's fast (uncontrolled under the hood), tiny, and removes almost all the boilerplate.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// One schema describes the shape AND the rules.
const schema = z.object({
username: z.string().min(3, 'At least 3 characters'),
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
});
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(schema) });
const onSubmit = async (data) => {
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} placeholder="Username" />
{errors.username && <p role="alert">{errors.username.message}</p>}
<input {...register('email')} placeholder="Email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register('password')} placeholder="Password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>Sign up</button>
</form>
);
}
π‘ Rule of thumb
A login or contact form? Plain React state is perfect. A multi-step wizard, dozens of fields, or intricate validation? Reach for React Hook Form + Zod and let the library carry the weight. You now understand what it's doing under the hood.
Hands-on Exercise
ποΈ Build a Validated Sign-Up Form
Objective: Create a controlled sign-up form (username, email, password, confirm password) with per-field validation and a submitting state β using only React state, no libraries.
Instructions:
- Hold all fields in one
formstate object with a singlehandleChange. - Track a
touchedobject; mark a field touched ononBlur. - Write a
validate(form)function: username β₯ 3 chars, valid email, password β₯ 8 chars, and confirm-password must match. - Show a field's error only when it's touched and invalid; wire
aria-invalidandrole="alert". - On submit, validate everything; if clean, set a
submittingstate and log the data.
π‘ Hint
Derive errors on every render with const errors = validate(form) rather than storing them in state β it stays in sync automatically. The form is valid when Object.keys(errors).length === 0.
β Solution
import { useState } from 'react';
function validate(form) {
const errors = {};
if (form.username.trim().length < 3)
errors.username = 'At least 3 characters';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
errors.email = 'Enter a valid email';
if (form.password.length < 8)
errors.password = 'At least 8 characters';
if (form.confirm !== form.password)
errors.confirm = 'Passwords do not match';
return errors;
}
export default function SignUpForm() {
const [form, setForm] = useState({
username: '', email: '', password: '', confirm: '',
});
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
const errors = validate(form);
const isValid = Object.keys(errors).length === 0;
const handleChange = (e) =>
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const handleBlur = (e) =>
setTouched((t) => ({ ...t, [e.target.name]: true }));
const handleSubmit = async (e) => {
e.preventDefault();
setTouched({ username: true, email: true, password: true, confirm: true });
if (!isValid) return;
setSubmitting(true);
console.log('Submitting', form);
// await fetch('/api/signup', { method: 'POST', body: JSON.stringify(form) });
setSubmitting(false);
};
const field = (name, type = 'text', label = name) => {
const invalid = touched[name] && errors[name];
return (
<div className="form-group">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={form[name]}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={Boolean(invalid)}
aria-describedby={invalid ? `${name}-error` : undefined}
/>
{invalid && (
<div id={`${name}-error`} role="alert" className="error-message">
{errors[name]}
</div>
)}
</div>
);
};
return (
<form onSubmit={handleSubmit} noValidate>
{field('username', 'text', 'Username')}
{field('email', 'email', 'Email')}
{field('password', 'password', 'Password')}
{field('confirm', 'password', 'Confirm password')}
<button type="submit" disabled={submitting}>
{submitting ? 'Creating accountβ¦' : 'Sign up'}
</button>
</form>
);
}
π― Quick Quiz
Question 1: In a controlled input, where does the input's displayed value come from?
Question 2: Why does the single generic handleChange use the input's name attribute?
Question 3: Your client-side validation passes. What must you still do before storing the data?
Summary & Quiz
π Key Takeaways
- Controlled components tie inputs to React state (the usual choice); uncontrolled ones read from the DOM via refs (simple forms, file inputs).
- Scale multi-field forms with one state object and a single generic change handler keyed on
name. - Prefer validate-on-blur, show errors only for touched fields, and remember client validation is UX β the server must re-validate.
- Make forms accessible: matched labels,
aria-invalid,aria-describedby, androle="alert"errors. - Track a submission status to disable the button and show progress; reach for React Hook Form + Zod when forms get large.
π Further Reading
- React docs β <input> and controlled components
- React Hook Form β official docs
- MDN β Web forms & accessibility
π What's Next?
Your forms now collect and validate data β but where does it go? Next, Connecting React Frontend to Express Backend wires this UI to a real API: sending requests, handling responses, and closing the full-stack loop.
π Forms conquered!
You can now build inputs that are controlled, validated, accessible, and ready to talk to a server.