Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~23 min· topic 6 of 7

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 id of 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" and minLength.
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.

validate.jswhole filejsx
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.

Signup.jsxwhole filejsx
const errors = validate(form);
const show = (name) => (touched[name] || submitted) && errors[name];
Derive errors, decide visibilitydiagram
Rendering diagram…

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.

Signup.jsxwhole filejsx
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.

Signup.jsxwhole filejsx
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.

Then the server checks againdiagram
Rendering diagram…

Try it yourself

  1. 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. 2

    Add a confirm-password rule

    Add a confirm field and the rule if (confirm !== password) errors.confirm = "Passwords don't match.". Because errors are derived, changing either password field updates the message.

  3. 3

    Let the browser talk

    In the second example, add noValidate to the form and press Send with empty fields. onSubmit now runs even though the form is invalid: you're responsible for checking e.currentTarget.checkValidity().

Code & diagrams

Validate on blur and on submit, with accessible errors New tab

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

Press Run to see the page.

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: email
The browser's own constraint validation New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

form valid? false | email empty? true
type mismatch? true
onSubmit ran with asha@example.com

Break 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.

terminal
$ Preview
── what you'll see ──
After a failed submit, typing a valid email still shows "Enter your email." until the next submit. Worse, handleSubmit checks the old errors, so it can block a valid form or send an invalid one.

Break #2

Show the error only in colour

Remove the <p className="error"> and keep only the red border from aria-invalid.

terminal
$ Screen reader and colour-blind users
── what you'll see ──
"Email, edit text, invalid entry" with no reason given; people who can't tell red from grey see nothing at all.

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-invalid should be absent (or false) on fields without errors; writing aria-invalid={false} renders aria-invalid="false", which is valid but noisy. Passing undefined leaves 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.

  • ▸

    setCustomValidity makes 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-invalid is supported in all major browsers since 2023.

Remember this

  1. 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. 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. 3

    Touched state remembers which fields the user has visited: const [touched, setTouched] = useState({}) and onBlur={(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. 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 an id and point to it from the field with aria-describedby, so the message is read when the field is focused. Make ids unique with useId() (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. 5

    Native constraint validation is built into HTML: required, type="email", minLength, maxLength, min, max and pattern. These work in React too. The browser checks them before the submit event: if a field fails, it shows its own bubble and **onSubmit never runs**. Add noValidate on the form to switch off the bubbles while keeping the checks, then use form.checkValidity() and each field's validity object (valueMissing, typeMismatch, tooShort…) in your own UI. setCustomValidity("message") adds your own rule to the browser's check.

  6. 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

01

Compare validating on submit, on blur and on change. Which would you choose and why?

02

Why derive errors from values instead of storing them in state?

03

How do you make an error message accessible?

04

How does native constraint validation interact with onSubmit, and what does noValidate change?

05

Why must the server validate again?

Practice

01

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.

02

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 validate function 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-invalid and aria-describedby, and move focus on failure.

  • I can use and turn off native constraint validation.

  • I always plan for the server to validate again.