Topic 5.6
Validation and Error Messages
In one line
Validate with one function that turns values into errors, decide when to show them (on submit, on blur with a touched flag, or on change), connect each message to its field with aria-invalid and aria-describedby, use the browser's built-in checks where they fit, and always validate again on the server.
Think of it like this
A teacher checking homework. A good teacher doesn't shout "wrong!" while you're still writing the first word. They wait until you finish a question (or hand in the sheet), then point at the exact line with a clear note: "add the units". Good form validation works the same way: the right moment, the right place, a helpful message.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Validation
- Checking that what the user entered is acceptable before using it.
- validate function
- A pure function that takes the form values and returns an object of error messages.
- onBlur
- React's event for "this field lost focus": the user moved to another field or clicked elsewhere.
- Touched
- A field the user has visited and left at least once. Used to decide when to show its error.
- aria-invalid
- An attribute that tells assistive technology a field's value is not valid.
- aria-describedby
- Points from a field to the
idof an element that describes it, such as its error message. Screen readers read it with the field. - Constraint validation
- The browser's built-in checks from attributes like
required,type="email"andminLength. - noValidate
- A form attribute that turns off the browser's error bubbles so you can show your own messages.
- Server-side validation
- Checking the data again on the server, where users can't skip or change the checks.
Step by step
01Write the rules once
A plain function: easy to read, easy to test, reusable on submit, blur and change.
function validate({ email, password }) {
const errors = {};
if (!email.trim()) errors.email = "Enter your email.";
else if (!/^\S+@\S+\.\S+$/.test(email)) errors.email = "Enter an email like name@example.com.";
if (password.length < 8) errors.password = "Use at least 8 characters.";
return errors;
}02Derive errors, decide visibility
Errors come from the values on every render. touched and submitted decide whether to show them.
const errors = validate(form);
const show = (name) => (touched[name] || submitted) && errors[name];03Mark the field and link the message
The field says it is invalid and points at the message's id. The message lives right under the field.
const emailErrorId = useId();
<label htmlFor="email">Email</label>
<input id="email" name="email" value={form.email} onChange={handleChange} onBlur={handleBlur}
aria-invalid={show("email") ? true : undefined}
aria-describedby={show("email") ? emailErrorId : undefined} />
{show("email") && <p id={emailErrorId} className="error">{errors.email}</p>}04On submit: block, show all, focus the first
Mark the form as submitted so every error appears, and put the keyboard focus on the first broken field.
function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
const first = Object.keys(errors)[0];
if (first) {
e.currentTarget.elements[first].focus();
return;
}
send(form);
}05Then the server checks again
The request can come from anywhere, so the server runs the same rules and answers with errors the form can display.
Try it yourself
- 1
Validate on change only after submit
In the first example, delete
onBlur={handleBlur}. Now errors appear only after the first submit, then update as you type. Which feels better for the email field? For the password field? - 2
Add a confirm-password rule
Add a
confirmfield and the ruleif (confirm !== password) errors.confirm = "Passwords don't match.". Because errors are derived, changing either password field updates the message. - 3
Let the browser talk
In the second example, add
noValidateto the form and press Send with empty fields.onSubmitnow runs even though the form is invalid: you're responsible for checkinge.currentTarget.checkValidity().
Code & diagrams
An empty submit was sent from code: every error shows, the email field is marked invalid and linked to its message, and focus moved to it. Now type: each message updates live, because errors are worked out from the values on every render.
Preview
Console
Nothing logged yet.Expected console output
blocked: { email: 'Enter your email.', password: 'Use at least 8 characters.' }
aria-invalid: true
described by its message: Enter your email.
focused: emailThe first `requestSubmit` does nothing visible in the console: the browser found an empty required field and stopped before the submit event. Press Send yourself with empty fields to see the browser's bubble. `:user-invalid` styles a field only after the user has interacted with it.
Preview
Console
Nothing logged yet.Expected console output
form valid? false | email empty? true
type mismatch? true
onSubmit ran with asha@example.comBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Store errors in state and forget to update them
In the first example, replace const errors = validate(form) with const [errors, setErrors] = useState({}) and call setErrors(validate(form)) only in handleSubmit.
Break #2
Show the error only in colour
Remove the <p className="error"> and keep only the red border from aria-invalid.
Myth vs fact
Myth
Client-side validation protects my server.
Fact
It is skipped by anyone who calls your API directly or edits the page. Only server-side validation protects data; client-side validation is a convenience for honest users.
Myth
Validate every field on every keystroke for the best UX.
Fact
Errors that appear while someone is still typing feel like being told off. Show them after blur or submit, then update live while the user fixes them.
Myth
Native HTML validation doesn't work in React.
Fact
required, type, minLength and pattern work as usual. React just renders the attributes. Use noValidate when you want your own messages.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
aria-invalidshould be absent (orfalse) on fields without errors; writingaria-invalid={false}rendersaria-invalid="false", which is valid but noisy. Passingundefinedleaves the attribute out. For a summary of errors at the top of a long form, use a heading with links to each field and move focus to it on submit. - ▸
Async checks ("is this username taken?") should be debounced (Topic 10.2), cancel stale requests (Topic 7.3), and still be repeated on the server, because the answer can change between the check and the submit.
- ▸
setCustomValiditymakes a field invalid until you call it again with"". In React you set it from an effect or the change handler through a ref; forgetting to clear it leaves the form permanently blocked. - ▸
The CSS pseudo-classes
:invalid(matches right away, even on an untouched required field) and:user-invalid(only after the user has interacted or tried to submit) let you style native validation without JavaScript;:user-invalidis supported in all major browsers since 2023.
Remember this
- 1
Validation means checking that what the user entered is acceptable: required fields aren't empty, an email looks like an email, a password is long enough. Write it as one pure function from values to errors:
validate(values)returns an object such as{ email: "Enter your email." }, empty when everything is fine. Errors are then derived during render,const errors = validate(form), not stored in their own state that could drift out of step (Topic 6.2). - 2
When to validate is a design choice. On submit: check everything when the user presses Submit; quiet while typing, but feedback arrives late. On blur: check a field when the user leaves it (
onBlur, the event fired when a field loses focus). On change: check on every keystroke; great for things like password strength, annoying for "invalid email" while someone is halfway through typing it. A popular mix: show a field's error after it has been touched (blurred once) or after the first submit, then update it live as they fix it. - 3
Touched state remembers which fields the user has visited:
const [touched, setTouched] = useState({})andonBlur={(e) => setTouched({ ...touched, [e.target.name]: true })}. Show an error only when(touched.email || submitted) && errors.email. This keeps an empty form from opening covered in red. - 4
Accessible errors need more than red text. Put
aria-invalid={true}on a field that has an error, so screen readers announce "invalid". Give the message anidand point to it from the field witharia-describedby, so the message is read when the field is focused. Make ids unique withuseId()(React 18, Topic 10.4). On a failed submit, move focus to the first invalid field, and don't rely on colour alone: use text (and an icon if you like). See the HTML course (/html) for labels and forms. - 5
Native constraint validation is built into HTML:
required,type="email",minLength,maxLength,min,maxandpattern. These work in React too. The browser checks them before the submit event: if a field fails, it shows its own bubble and **onSubmitnever runs**. AddnoValidateon the form to switch off the bubbles while keeping the checks, then useform.checkValidity()and each field'svalidityobject (valueMissing,typeMismatch,tooShort…) in your own UI.setCustomValidity("message")adds your own rule to the browser's check. - 6
Never trust the client alone. Anyone can turn off JavaScript, edit the page in DevTools, or send a request straight to your API with any data. Client-side validation is for user experience: fast, friendly feedback. The server must validate everything again and return clear errors your form can show. Sharing one schema between client and server (Topic 5.7) keeps the two in step.
Explain it without notes
Compare validating on submit, on blur and on change. Which would you choose and why?
Why derive errors from values instead of storing them in state?
How do you make an error message accessible?
How does native constraint validation interact with onSubmit, and what does noValidate change?
Why must the server validate again?
Practice
Write a validate function for a contact form with name (required) and message (at least 10 characters), and show each error under its field after the first submit.
Add aria-invalid and aria-describedby to a single username field that must be 3–15 characters, showing the error after the field is blurred.
Trade-offs
- ↔
Native validation is free, fast and works without JavaScript, but its bubbles can't be styled and their wording depends on the browser and language. Custom validation looks consistent and can express any rule, at the cost of code and accessibility work.
- ↔
Showing errors early helps people fix things sooner but can feel hostile; showing them late is calmer but sends people back up the form. Touched-then-live is the usual compromise.
Done when you can
I can write a pure
validatefunction and derive errors during render.I can show errors on submit, on blur with touched state, or on change.
I connect errors to fields with
aria-invalidandaria-describedby, and move focus on failure.I can use and turn off native constraint validation.
I always plan for the server to validate again.