Forms are one of the most common things you'll build in React: login screens, checkout flows, search bars, contact pages. This week covers the essentials: how React thinks about form inputs, how to manage their state, and how to give users useful feedback when something goes wrong.
Fair warning: forms are not the most exciting topic. But the patterns here are ones you'll use constantly. Later in the course you'll use React Hook Form, which handles a lot of the boilerplate for you (but understanding what it's doing underneath makes you much better at using it).
The first thing to understand about React forms is that there are two ways to handle inputs, and they work very differently.
An uncontrolled input manages its own value internally, the way a regular HTML form does. React doesn't know what's in it: you only find out when the form submits.
function SearchForm() {
function handleSubmit(e) {
e.preventDefault();
const query = e.target.search.value; // read the value on submit
console.log('Searching for:', query);
}
return (
<form onSubmit={handleSubmit}>
<input name="search" type="text" defaultValue="" />
<button type="submit">Search</button>
</form>
);
}
Notice defaultValue instead of value. That sets the initial value without React taking over control of the input. The DOM itself owns the value after that point.
Uncontrolled inputs are fine for simple cases: a quick search bar, a file upload, a form where you only care about the data on submission. They're less useful when you need to react to what the user is typing as they type it (live validation, dependent fields, character counts).
A controlled input is one where React state holds the value. Every keystroke updates state, and the input renders whatever is in that state. React is now the single source of truth for the input's value.
function SearchForm() {
const [query, setQuery] = useState('');
function handleSubmit(e) {
e.preventDefault();
console.log('Searching for:', query); // already in state
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
);
}
The key difference: value={query} ties the input to state, and onChange updates that state on every keystroke. Without the onChange, the input would appear frozen โ React would keep rendering the same value and block any typing.
<aside> โ ๏ธ
Warning
If you set value on an input without providing an onChange handler, React will warn you and the field will be read-only. Either add an onChange, or use defaultValue for an uncontrolled input.
</aside>
The benefit of controlled inputs is that your state always reflects what's in the form. You can act on it at any point: validate as the user types, enable/disable a submit button, transform the value as it's entered. Most forms you build in React will use the controlled approach.
<aside> ๐ก
Info
There are a few exceptions where uncontrolled is genuinely better: file inputs (<input type="file">) are always uncontrolled because their value can't be set programmatically. Some complex third-party integrations also work better as uncontrolled. For everything else, controlled is the default.
</aside>
For forms with more than one field, you'll want a single state object rather than one useState call per field. It keeps things tidy and makes submission simple.
function RegisterForm() {
const [form, setForm] = useState({
username: '',
email: '',
password: '',
});
function handleChange(e) {
setForm({
...form,
[e.target.name]: e.target.value,
});
}
function handleSubmit(e) {
e.preventDefault();
console.log('Submitting:', form);
// send to your API here
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
type="text"
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">Create account</button>
</form>
);
}
One handleChange function serves every field, using e.target.name as the key. Each input just needs a name attribute that matches its key in the state object. Adding a new field means adding it to the state object and adding the input (nothing else needs to change).
<aside> โจ๏ธ
Hands On
Build the registration form above. Then add a "Display name" field without changing the handleChange function. Just add the field to the initial state and add the <input> to the JSX. Confirm it works. That's the ergonomic benefit of the single-handler pattern.
</aside>
Validation is about giving users accurate, timely feedback. The goal is to tell them what's wrong and where, as helpfully as possible.
There are two natural moments to validate a field:
On blur โ when the user leaves a field. Good for format checks (is this a valid email?). Avoids showing errors while someone is still typing.
On submit โ when the form is submitted. Always validate here, even if you're also validating on blur, to catch anything that was skipped.
A common pattern is to combine both: validate on blur so the user gets feedback as they fill in the form, and re-validate everything on submit before sending data anywhere.
Keep errors in a separate state object that mirrors the shape of the form:
function ContactForm() {
const [form, setForm] = useState({
name: '',
email: '',
message: '',
});
const [errors, setErrors] = useState({});
function validate(fields) {
const newErrors = {};
if (!fields.name.trim()) {
newErrors.name = 'Name is required.';
}
if (!fields.email.trim()) {
newErrors.email = 'Email is required.';
} else if (!/\\S+@\\S+\\.\\S+/.test(fields.email)) {
newErrors.email = 'Please enter a valid email address.';
}
if (!fields.message.trim()) {
newErrors.message = 'Message is required.';
} else if (fields.message.trim().length < 20) {
newErrors.message = 'Message must be at least 20 characters.';
}
return newErrors;
}
function handleChange(e) {
const updated = { ...form, [e.target.name]: e.target.value };
setForm(updated);
// Clear the error for this field as soon as the user starts correcting it
if (errors[e.target.name]) {
setErrors({ ...errors, [e.target.name]: undefined });
}
}
function handleBlur(e) {
// Validate just this field when the user leaves it
const fieldErrors = validate(form);
if (fieldErrors[e.target.name]) {
setErrors({ ...errors, [e.target.name]: fieldErrors[e.target.name] });
}
}
function handleSubmit(e) {
e.preventDefault();
const validationErrors = validate(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return; // stop here โ don't submit
}
console.log('Sending:', form);
// your API call goes here
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={form.name}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<span id="name-error" role="alert">
{errors.name}
</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={form.message}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby={errors.message ? 'message-error' : undefined}
aria-invalid={!!errors.message}
/>
{errors.message && (
<span id="message-error" role="alert">
{errors.message}
</span>
)}
</div>
<button type="submit">Send message</button>
</form>
);
}
A few things worth calling out:
noValidate on the <form> disables the browser's built-in HTML5 validation pop-ups, so you're fully in control of validation UI.
aria-invalid and aria-describedby connect the error message to its input for screen readers. A user navigating by keyboard will hear the error message read out when they focus the invalid field. This is a small addition that makes a meaningful difference.
role="alert" on the error span makes screen readers announce it immediately when it appears, without the user needing to navigate to it.
Clearing errors on change gives users the satisfaction of errors disappearing as they fix them, rather than only clearing on the next submit attempt.
A common pattern is to disable the submit button while the form has validation errors. Use it with care โ disabling a button without explanation can be frustrating. It works well when paired with inline validation so the user already knows what needs fixing:
const hasErrors = Object.values(errors).some(Boolean);
const isIncomplete = !form.name || !form.email || !form.message;
<button type="submit" disabled={hasErrors || isIncomplete}>
Send message
</button>
A few input types need slightly different handling.
Checkboxes use checked instead of value, and you read e.target.checked in the handler:
const [agreed, setAgreed] = useState(false);
<input
type="checkbox"
id="terms"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<label htmlFor="terms">I agree to the terms</label>
Select elements work just like text inputs โ value and onChange:
const [country, setCountry] = useState('nl');
<select
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
>
<option value="nl">Netherlands</option>
<option value="de">Germany</option>
<option value="be">Belgium</option>
</select>
When you use a single form state object, these fit right in โ just make sure the checkbox field uses e.target.checked in the handleChange function:
function handleChange(e) {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setForm({ ...form, [e.target.name]: value });
}
<aside> ๐
Celebration
Forms covered. You can build a controlled form, validate its fields, show meaningful error messages, and handle the full range of input types. That's a complete, production-usable pattern โ even before you pick up any libraries.
</aside>
noValidate opts you out of. Understanding what you're replacing is useful.aria- attributes in this lesson come from these patterns.The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.