Forms
By the end of this lesson
Build accessible controlled forms with validation.
A form is where most of the difficulty in a React application concentrates. It holds state, it validates, it talks to a server, it can fail, and it has to work for someone using a keyboard and a screen reader.
The running example is the form for adding an employee to the directory: a name, an email address, a department and a start date. Everything here applies equally to a login form or a filter panel.
A controlled input is one whose displayed value comes from state. The loop is worth following once, because it explains why the input appears to work even though you never told it to update:
The input renders with value from state
value={form.name} means the field shows whatever state currently holds. At the start, that is an empty string.
The user presses a key
The browser fires a change event. React calls your onChange handler with it.
The handler sets state
You read event.target.value and pass it to the setter. Nothing visible happens yet.
React renders again
The component runs with the new state, so value is now the updated string and the field displays it.
Why this is worth the extra step
State is the only place the value lives, so any code can read it, validate it, disable the submit button from it, or clear it. An input left to manage its own value keeps that information in the DOM, where the rest of your component cannot see it.
"use client";
import { useState } from "react";
export function AddEmployeeForm() {
const [name, setName] = useState("");
return (
<form>
<div className="flex flex-col gap-1">
<label htmlFor="employee-name">Full name</label>
<input
id="employee-name"
name="name"
type="text"
autoComplete="name"
value={name}
onChange={(event) => setName(event.target.value)}
className="rounded border px-3 py-2"
/>
</div>
<button type="submit">Add employee</button>
</form>
);
}- htmlFor on the label matches id on the input. This is the association that makes a screen reader announce "Full name, edit text" and makes clicking the label focus the field. It is not optional and there is no CSS substitute for it.
- value and onChange together make the input controlled. Supply value without onChange and the field becomes read-only, which produces a React warning and a confused user.
- autoComplete="name" lets the browser offer the user's own details. It is a small kindness that costs one attribute and helps anyone who finds typing difficult.
- type="submit" on the button is deliberate. A button inside a form submits by default, and being explicit avoids the situation where someone adds type="button" for styling reasons and silently breaks submitting with the Enter key.
"use client";
import { useState } from "react";
interface FormValues {
name: string;
email: string;
}
type Errors = Partial<Record<keyof FormValues, string>>;
function validate(values: FormValues): Errors {
const errors: Errors = {};
if (values.name.trim() === "") errors.name = "Enter the employee's full name.";
if (!values.email.includes("@")) errors.email = "Enter a valid work email address.";
return errors;
}
export function AddEmployeeForm({ onCreated }: { onCreated: () => void }) {
const [values, setValues] = useState<FormValues>({ name: "", email: "" });
const [errors, setErrors] = useState<Errors>({});
const [isPending, setIsPending] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
function handleChange(field: keyof FormValues, value: string) {
setValues((current) => ({ ...current, [field]: value }));
// Clear this field's error only once it is actually resolved.
setErrors((current) => {
if (!current[field]) return current;
const stillInvalid = validate({ ...values, [field]: value })[field];
if (stillInvalid) return current;
const next = { ...current };
delete next[field];
return next;
});
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
setIsPending(true);
setSubmitError(null);
try {
const response = await fetch("/api/employees", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) throw new Error("Request failed");
setValues({ name: "", email: "" });
onCreated();
} catch {
setSubmitError("The employee could not be added. Try again.");
} finally {
setIsPending(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-1">
<label htmlFor="name">Full name</label>
<input
id="name"
value={values.name}
onChange={(event) => handleChange("name", event.target.value)}
aria-invalid={errors.name ? true : undefined}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{errors.name && (
<p id="name-error" className="text-sm text-red-700">
{errors.name}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Adding..." : "Add employee"}
</button>
<p role="alert" className="text-sm text-red-700">
{submitError}
</p>
</form>
);
}- aria-describedby points at the id of the error message, so a screen reader reads the field's label, then its value, then the error. Without it the message is visible text that has no connection to the input it describes.
- aria-invalid marks the field itself as failing. It is set to undefined rather than false when valid, because the attribute is then absent entirely rather than present and denying a problem.
- The error paragraph is rendered only when there is an error, and its id matches what aria-describedby expects. If you render the paragraph always, keep the describedby always — a reference to an empty element is confusing.
- handleChange clears a field's error only when the new value actually fixes it. Clearing on the first keystroke makes the message vanish while the field is still wrong, which loses the one piece of guidance the user had.
- event.preventDefault() stops the browser's own form submission. Without it the page reloads and your handler's work is discarded.
- The submit button is disabled while the request is in flight, and its text changes. Both matter: the disabled state prevents a duplicate employee from a double click, and the text tells someone using a screen reader that something is happening.
- role="alert" on the submit error means assistive technology announces it when it appears. The element is rendered even when empty so that the announcement fires on the change — an element that appears from nothing is sometimes missed.
- noValidate turns off the browser's own validation messages so there is one consistent set of errors rather than two competing ones. Keep the correct input types regardless; they still control the mobile keyboard.
The accessibility requirements for a form, in the order they tend to be forgotten:
- Every input has a real label, associated by htmlFor and id. A placeholder is not a label — it disappears on the first keystroke and is often too faint to read
- Errors are associated with their field by aria-describedby, not merely positioned near it
- Error text says what to do, not what went wrong. "Enter a valid work email address" beats "Invalid input"
- Colour is never the only signal. A red border plus a message; never a red border alone
- Validation errors on submit move focus to the first invalid field, or to a summary listing them, so a keyboard user does not have to hunt
- Related controls are grouped in a fieldset with a legend — a set of radio buttons for employment type, for example
- Required fields are marked in the label text as well as with the required attribute, because "required" in the accessible name is unambiguous
Summary
- A controlled input takes its value from state and reports changes through onChange
- Labels must be associated with inputs by htmlFor and id; a placeholder is not a label
- Errors need a programmatic link to their field via aria-describedby, plus aria-invalid on the input
- Clear a field's error when it is resolved, not on the first keystroke
- Disable submit while a request is in flight, and say what is happening in the button text
- Uncontrolled inputs read on submit are less code when nothing depends on the value while typing
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Add a department select and a start date to the form above. The department is required; the start date must not be in the future.
Then make submit move focus to the first field with an error. Decide where that focus code belongs, and why it is not in an effect.
Show solution
Focus moves inside the submit handler, because the reason for it is the submit. Putting it in an effect that watches the errors object would also fire when errors appear for other reasons — clearing a field, a server response — and would move focus while the user was typing somewhere else.
The code queries for the first invalid control rather than keeping a ref per field. With four fields either approach is fine; the query stays short as fields are added, at the cost of depending on the aria-invalid attribute being set correctly. That dependency is reasonable, since the attribute has to be right anyway.
Validating the date against today's date is a reminder that validation is a pure function of the values plus the current context. Keep it outside the component, as validate is here, and it becomes straightforward to test without rendering anything.
function validate(values: FormValues): Errors {
const errors: Errors = {};
if (values.name.trim() === "") errors.name = "Enter the employee's full name.";
if (!values.email.includes("@")) errors.email = "Enter a valid work email address.";
if (values.department === "") errors.department = "Choose a department.";
if (values.startDate === "") {
errors.startDate = "Enter a start date.";
} else if (new Date(values.startDate) > new Date()) {
errors.startDate = "The start date cannot be in the future.";
}
return errors;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) {
// Move focus where the user needs to act, because they submitted.
const firstInvalid =
event.currentTarget.querySelector<HTMLElement>("[aria-invalid='true']");
firstInvalid?.focus();
return;
}
// ...send the request
}Think about it
Think about it
Your form disables the submit button until every field is valid. A colleague argues this is worse for users than leaving it enabled and validating on submit.
What is their case, and what would change your mind either way?
Show solution
Their case is that a disabled button explains nothing. The user sees a control they cannot use and no statement of what is missing, and a screen reader user may not reach it at all, since disabled controls are commonly skipped in the tab order. Submitting and being told precisely what to fix gives them information; a dead button gives them a puzzle.
The argument for disabling is preventing pointless failed requests, which matters more when submitting is expensive or has side effects.
A reasonable middle ground: keep the button enabled, validate on submit, show the errors, and move focus to the first one. Then disable it only while a request is in flight, where the disabled state has a clear and temporary meaning that the changed button text explains.
What should change your mind is evidence about your own users — support requests about a stuck form, or watching someone use it. This is a genuine trade-off between two defensible designs, not a rule with a correct answer.
Saved in this browser only.