Form Validation
Validation is how a form tells the user, kindly and early, that something needs fixing before it can be sent. Done well it prevents bad data, reduces failed server requests, and guides people to success instead of scolding them. React gives you three layers to work with: the browser’s built-in HTML attributes, the Constraint Validation API for reading native results in JavaScript, and your own state-driven rules for anything custom. This page shows how to combine them and present errors accessibly.
Native attributes: the cheapest validation
Before writing any JavaScript, lean on the browser. Attributes like required, type, minLength, maxLength, and pattern block submission and display native messages automatically. They cost nothing and work even if your scripts fail to load.
| Attribute | Validates | Example |
|---|---|---|
required | A value is present | <input required /> |
type="email" | Looks like an email | <input type="email" /> |
minLength / maxLength | String length bounds | <input minLength={8} /> |
min / max | Numeric range | <input type="number" min={1} /> |
pattern | Matches a regex | <input pattern="[0-9]{5}" /> |
function ZipForm() {
return (
<form>
<input
name="zip"
required
pattern="[0-9]{5}"
title="Enter a 5-digit ZIP code"
/>
<button type="submit">Save</button>
</form>
);
}
When a field fails, the browser focuses it and shows a tooltip. The title text is reused as the message for pattern mismatches, so always write a helpful one.
Native validation styling and message wording are inconsistent across browsers and can’t be fully customized. For branded forms you usually add
noValidateto the<form>and take over with the Constraint Validation API plus your own UI.
Reading results with the Constraint Validation API
Every form control exposes a validity object and a validationMessage string. This lets you keep the browser’s rule engine while rendering your own error text. The key method is checkValidity(), which returns a boolean and fires an invalid event on failing fields.
import { useState } from "react";
function SubscribeForm() {
const [error, setError] = useState("");
function handleSubmit(e) {
e.preventDefault();
const input = e.target.elements.email;
if (!input.checkValidity()) {
setError(input.validationMessage);
return;
}
setError("");
console.log("Valid:", input.value);
}
return (
<form onSubmit={handleSubmit} noValidate>
<input name="email" type="email" required aria-invalid={!!error} />
{error && <p role="alert">{error}</p>}
<button type="submit">Subscribe</button>
</form>
);
}
Output:
Valid: [email protected]
Because we added noValidate, the native popup is suppressed and we control the message entirely. The validity object also has granular flags—valueMissing, typeMismatch, patternMismatch, tooShort—if you want to tailor messages per rule.
Custom validation in state
Most real forms need rules HTML can’t express: passwords that match, dates in the future, fields required only when another is checked. Keep an errors object in state, validate each field with a pure function, and render the matching message under the input.
import { useState } from "react";
const initial = { email: "", password: "" };
function validate(values) {
const errors = {};
if (!values.email.includes("@")) {
errors.email = "Enter a valid email address.";
}
if (values.password.length < 8) {
errors.password = "Password must be at least 8 characters.";
}
return errors;
}
function SignupForm() {
const [values, setValues] = useState(initial);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function handleChange(e) {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
}
function handleBlur(e) {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
setErrors(validate({ ...values }));
}
function handleSubmit(e) {
e.preventDefault();
const nextErrors = validate(values);
setErrors(nextErrors);
setTouched({ email: true, password: true });
if (Object.keys(nextErrors).length === 0) {
console.log("Submitting", values);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<label>
Email
<input
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={!!(touched.email && errors.email)}
aria-describedby="email-error"
/>
</label>
{touched.email && errors.email && (
<p id="email-error" role="alert">{errors.email}</p>
)}
<label>
Password
<input
name="password"
type="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={!!(touched.password && errors.password)}
aria-describedby="password-error"
/>
</label>
{touched.password && errors.password && (
<p id="password-error" role="alert">{errors.password}</p>
)}
<button type="submit">Create account</button>
</form>
);
}
When to validate
The timing of validation shapes how the form feels. Validate too eagerly and you flash errors while someone is still typing; validate too late and they fix everything at once after a failed submit.
| Strategy | Trigger | Feels like |
|---|---|---|
| On submit | Submit only | Calm, but late feedback |
| On blur | Leaving a field | Balanced—the common default |
| On change | Every keystroke | Instant, can be noisy |
A proven hybrid is the one above: validate on submit, then re-validate a field on change only after it has been blurred (its touched flag is set). This stays quiet until the user has had a fair chance, then becomes responsive.
Accessibility
Visual color cues are invisible to screen-reader users, so wire errors into the accessibility tree. Set aria-invalid="true" on a failing input, link it to its message with aria-describedby, and give the message role="alert" so it is announced when it appears. Always associate inputs with a <label> (the id/htmlFor pairing or wrapping) so the field has an accessible name.
Best Practices
- Let native attributes (
required,type,pattern) handle simple rules before reaching for custom logic. - Add
noValidateto the<form>when you render your own messages, so native popups don’t double up. - Track a
touchedflag per field and only show errors after the user has interacted with it. - Keep validation logic in a pure
validate(values)function so it is testable and reusable on submit and blur. - Set
aria-invalidandaria-describedbyon inputs androle="alert"on messages for screen-reader support. - Re-validate on the server too—client validation is for UX, never for security or data integrity.
- Focus the first invalid field on a failed submit so keyboard users land where the problem is.