Validate a controlled React form on submit

The problem

A controlled form that validates on every keystroke reports errors before the reader has finished entering anything, while a form that validates only in the submit handler can silently drop a field the rule set never mentions.

Short answer

Store touched state per field, run rules once on submit, render the returned message array by field name, and let the browser handle required and type checking unless you replace it.

Controlled values make the rules a pure function of state, which is the advantage worth taking.

Language: JSX
import { useState } from 'react';

const rules = {
  name: (values) => (values.name.trim().length < 2 ? 'Enter your full name.' : ''),
  email: (values) =>
    /^[^@\s]+@[^@\s.]+\.[^@\s]{2,}$/.test(values.email) ? '' : 'Enter a valid email address.',
  confirm: (values) => (values.confirm === values.email ? '' : 'The two addresses must match.'),
};

const initialValues = { name: '', email: '', confirm: '' };

export function SignUpForm({ onSubmit }) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  function handleChange(event) {
    const { name, value } = event.target;
    setValues((current) => ({ ...current, [name]: value }));
    // Clear a shown error as soon as the field changes; do not add new ones yet.
    if (touched[name]) {
      setErrors((current) => ({ ...current, [name]: rules[name](values) }));
    }
  }

  function handleBlur(event) {
    const { name } = event.target;
    setTouched((current) => ({ ...current, [name]: true }));
    setErrors((current) => ({ ...current, [name]: rules[name](values) }));
  }

  function handleSubmit(event) {
    event.preventDefault();
    const next = {};
    for (const [field, rule] of Object.entries(rules)) {
      const message = rule(values);
      if (message) next[field] = message;
    }
    setErrors(next);
    setTouched(Object.fromEntries(Object.keys(rules).map((field) => [field, true])));

    if (Object.keys(next).length === 0) onSubmit(values);
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {Object.keys(initialValues).map((field) => (
        <p key={field}>
          <label htmlFor={field}>{field}</label>
          <input
            id={field}
            name={field}
            value={values[field]}
            onChange={handleChange}
            onBlur={handleBlur}
            aria-invalid={Boolean(errors[field]) || undefined}
            aria-describedby={errors[field] ? `${field}-error` : undefined}
          />
          {errors[field] ? <span id={`${field}-error`}>{errors[field]}</span> : null}
        </p>
      ))}
      <button type="submit">Create account</button>
    </form>
  );
}

Explanation

The rule table is the important part: iterating Object.entries(rules) on submit means a new field without a rule is a visible omission in one place rather than a silent hole. The common version of this bug is a handler that checks each field by hand and forgets the one added last month. Deriving the error object from the same table the fields are rendered from keeps the two lists in step.

Validation timing is a state question rather than a rule question. Errors appear on submit or on blur after a field is touched, and never while a field is untouched, so an empty email input does not shout at the reader on the first character. Clearing an error as the value changes is a different action from adding one, and only the clearing is safe mid-typing.

Because the inputs are controlled with noValidate, this component owns the whole message path. That is a deliberate trade: it buys a consistent message style and cross-field rules such as the confirmation comparison, which native constraints cannot express, and it costs the free localisation, focus management, and constraint bubbles the platform provides. If you keep native validation, drop noValidate and read checkValidity() instead of duplicating the rules.

The aria-describedby attribute is applied only when a message exists. Pointing at an id that is not rendered leaves the reference dangling, and some combinations of browser and screen reader announce nothing rather than reporting the broken reference, which is hard to notice in testing.

Assumptions

  • React 19 with a function component; onSubmit is the caller’s side effect, kept out of the validation logic.
  • Field names are known at module scope; the rules and initialValues objects must list the same fields.

Parameters

  • onSubmit (function, required): called with the validated values object only when no rule produced a message.

Common mistakes

  • Adding an error on every keystroke, so the reader sees “Enter a valid email address.” while typing the first letter.
  • Reading values from the render closure inside handleChange, which reports against the previous value; shown here as an accepted trade-off for the blur path and worth closing with the functional update if you validate against the new value.

Caveats

  • Cross-field rules make clearing one field stale another’s message; the submit pass recomputes everything, the incremental path does not.
  • Hiding the message node when there is no error means the live-region announcement depends on insertion order; use a persistent described-by target when you need an announcement.

Alternatives

Validating on change gives immediate feedback but punishes the first keystroke of an email address; validating only on submit is calmer and matches native behaviour, at the cost of a later discovery. The middle path, validating a field only after it has been touched and left, needs the extra state shown here.

Prerequisite

Examples that treat this as a prerequisite

Derived at build time from the editorial graph; no edge is invented here.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References