Skip to content
React rc forms 5 min read

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.

AttributeValidatesExample
requiredA value is present<input required />
type="email"Looks like an email<input type="email" />
minLength / maxLengthString length bounds<input minLength={8} />
min / maxNumeric range<input type="number" min={1} />
patternMatches 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 noValidate to 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.

StrategyTriggerFeels like
On submitSubmit onlyCalm, but late feedback
On blurLeaving a fieldBalanced—the common default
On changeEvery keystrokeInstant, 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 noValidate to the <form> when you render your own messages, so native popups don’t double up.
  • Track a touched flag 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-invalid and aria-describedby on inputs and role="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.
Last updated June 14, 2026
Was this helpful?