Form Validation

Form validation is the process of checking user input against a set of rules — required fields, minimum lengths, valid email formats, matching passwords — and giving the user feedback before the data is submitted. React doesn’t ship a built-in validation library; instead, you build validation out of the same tools you already use for any controlled form: state, event handlers, and conditional rendering. Understanding this pattern well means you can validate anything, from a single email field to a multi-step signup wizard, without reaching for a third-party library.

Overview: How Form Validation Works in React

In React, form inputs are usually controlled components: the input’s value comes from state, and every keystroke fires an onChange handler that updates that state with setState. Because state changes trigger a re-render, the input always reflects the latest state, and — crucially for validation — you have a single source of truth you can inspect at any moment to decide whether the current value is valid.

Validation itself is just a plain JavaScript function: it takes the current values and returns either nothing (valid) or a description of what’s wrong (invalid). You store the validation results in a second piece of state, usually called errors, shaped as an object keyed by field name (e.g. { email: "Email is required." }). When errors.email is truthy, you conditionally render an error message next to the email field. When it’s falsy (or the key is absent), no message renders. This is the entire mechanism — everything else is a decision about when you call the validation function.

When to validate

  • On submit — validate everything when the form is submitted, and block the submit if there are errors. Simple and avoids nagging the user while they’re still typing.
  • On blur — validate a field once the user leaves it (the onBlur event). This gives feedback earlier without flashing errors on every keystroke.
  • On change — validate as the user types. Best reserved for fields already marked invalid (so the error can clear as soon as it’s fixed), rather than the first pass, which feels aggressive.

A common, user-friendly pattern combines all three: validate on blur (and on submit) to show errors, and validate on every change to fields that already have an error, so the error disappears the moment it’s fixed. You’ll see this pattern in the second example below, using a touched state object to track which fields the user has already interacted with.

Because validation is driven entirely by state and re-renders, it obeys the same rules as everything else in React: state updates are asynchronous and batched, so you should never read values or errors immediately after calling their setters and expect the new value — always compute the next value first (e.g. const nextValues = { ...values, [name]: value }) and pass that into your validation function directly, rather than trusting state that hasn’t re-rendered yet.

Syntax

There’s no special React API for validation — the “syntax” is really a small, repeatable shape you’ll reuse in nearly every form:

function validate(values) {
  const errors = {};
  if (/* rule fails */) {
    errors.fieldName = "Helpful message.";
  }
  return errors;
}

function MyForm() {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  function handleChange(e) { /* update values */ }
  function handleBlur(e)   { /* run validate, update errors */ }
  function handleSubmit(e) { /* e.preventDefault(); run validate; if none, submit */ }

  return <form onSubmit={handleSubmit}>...</form>;
}
Piece Purpose
values State object holding the current value of every field.
errors State object holding the current error message (if any) per field.
touched Optional state tracking which fields the user has already blurred, so errors don’t show prematurely.
validate(values) Pure function that returns an errors object; contains no side effects, so it’s easy to test on its own.
onChange Updates values; often also re-validates if the field is already marked invalid.
onBlur Marks a field as touched and runs validate so the error can appear.
onSubmit Calls e.preventDefault(), runs validate on everything, and only proceeds if the result is empty.

Examples

Example 1: Validate on submit

The simplest approach: don’t bother the user while typing, just check everything when they hit submit.

import { useState } from "react";

function EmailForm() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const [submitted, setSubmitted] = useState(false);

  function validateEmail(value) {
    if (!value.trim()) {
      return "Email is required.";
    }
    const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!pattern.test(value)) {
      return "Please enter a valid email address.";
    }
    return "";
  }

  function handleSubmit(e) {
    e.preventDefault();
    const validationError = validateEmail(email);
    setError(validationError);
    setSubmitted(validationError === "");
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="text"
        value={email}
        onChange={(e) => {
          setEmail(e.target.value);
          setSubmitted(false);
        }}
      />
      {error && <p role="alert">{error}</p>}
      {submitted && <p>Thanks, we'll be in touch!</p>}
      <button type="submit">Subscribe</button>
    </form>
  );
}

export default EmailForm;

This renders a labeled text input and a Subscribe button. Submitting with an empty or malformed value shows a red-flagged error paragraph (role="alert" helps screen readers announce it); submitting a valid address hides the error and shows a thank-you message. Notice noValidate on the <form> — it disables the browser’s built-in HTML5 validation bubble so your React-driven message is the only one shown.

Example 2: Validate on blur, with a touched state

Real signup forms usually have several fields, and you don’t want to show every error the instant the page loads. This example tracks which fields the user has actually visited.

import { useState } from "react";

function validate(values) {
  const errors = {};
  if (!values.username.trim()) {
    errors.username = "Username is required.";
  } else if (values.username.length < 3) {
    errors.username = "Username must be at least 3 characters.";
  }

  if (!values.password) {
    errors.password = "Password is required.";
  } else if (values.password.length < 8) {
    errors.password = "Password must be at least 8 characters.";
  }

  return errors;
}

function SignupForm() {
  const [values, setValues] = useState({ username: "", password: "" });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  function handleChange(e) {
    const { name, value } = e.target;
    const nextValues = { ...values, [name]: value };
    setValues(nextValues);
    if (touched[name]) {
      setErrors(validate(nextValues));
    }
  }

  function handleBlur(e) {
    const { name } = e.target;
    setTouched({ ...touched, [name]: true });
    setErrors(validate(values));
  }

  function handleSubmit(e) {
    e.preventDefault();
    const nextErrors = validate(values);
    setErrors(nextErrors);
    setTouched({ username: true, password: true });
    if (Object.keys(nextErrors).length === 0) {
      console.log("Submitting:", values);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="username">Username</label>
        <input
          id="username"
          name="username"
          value={values.username}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.username && errors.username && (
          <p role="alert">{errors.username}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.password && errors.password && (
          <p role="alert">{errors.password}</p>
        )}
      </div>

      <button type="submit">Create account</button>
    </form>
  );
}

export default SignupForm;

Output:

Submitting: {username: "alice", password: "hunter2pass"}

Errors only appear once a field has been blurred (touched.username is true) or after a submit attempt, which also force-marks every field as touched. Because handleChange re-validates using nextValues — the value about to be set — rather than the (still-stale) values state, an error can clear the instant the user fixes it, without waiting for the next render.

Example 3: A reusable useFormValidation custom hook

Once you’ve written this pattern twice, it’s worth extracting into a custom hook so every form in your app shares the same logic.

import { useState } from "react";

function useFormValidation(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  function handleChange(e) {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
  }

  function handleSubmit(onValid) {
    return (e) => {
      e.preventDefault();
      const nextErrors = validate(values);
      setErrors(nextErrors);
      if (Object.keys(nextErrors).length === 0) {
        onValid(values);
      }
    };
  }

  return { values, errors, handleChange, handleSubmit };
}

function validateSignup(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.";
  }
  if (values.confirmPassword !== values.password) {
    errors.confirmPassword = "Passwords do not match.";
  }
  return errors;
}

function SignupWithHook() {
  const { values, errors, handleChange, handleSubmit } = useFormValidation(
    { email: "", password: "", confirmPassword: "" },
    validateSignup
  );

  function onValid(formValues) {
    console.log("Account created for:", formValues.email);
  }

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <input name="email" value={values.email} onChange={handleChange} placeholder="Email" />
      {errors.email && <p role="alert">{errors.email}</p>}

      <input name="password" type="password" value={values.password} onChange={handleChange} placeholder="Password" />
      {errors.password && <p role="alert">{errors.password}</p>}

      <input name="confirmPassword" type="password" value={values.confirmPassword} onChange={handleChange} placeholder="Confirm password" />
      {errors.confirmPassword && <p role="alert">{errors.confirmPassword}</p>}

      <button type="submit">Sign up</button>
    </form>
  );
}

export default SignupWithHook;

This renders three password-style inputs plus a submit button. Submitting with a mismatched confirmation, a short password, or a malformed email shows the relevant message under each field; submitting valid values logs Output: Account created for: alice@example.com and shows no errors at all. The hook returns exactly the pieces a form needs (values, errors, and the two handlers) while hiding the state management inside — any component can now get full validation behavior in four lines.

How It Works Step by Step

  • On mountvalues is initialized (usually to empty strings) and errors starts as {}, so no error messages render yet.
  • On keystrokeonChange fires, computes the next values object immutably, and calls setValues. React schedules a re-render; the input’s value now reflects what was typed. If the field is already flagged invalid (or you always re-validate on change), setErrors is also called with a freshly computed errors object.
  • On bluronBlur marks the field as touched and runs validate against the current values, populating errors for the first time for that field. Because touched gates whether the message renders, fields the user hasn’t reached yet stay silent.
  • On submitonSubmit calls e.preventDefault() to stop the browser’s native page reload, runs validate against every field, marks everything as touched (so every remaining error becomes visible), and only proceeds with the “real” submit logic (an API call, a console.log, a navigation) if the resulting errors object is empty.
  • On re-render — React compares the new JSX (with the updated errors) to the previous render during reconciliation, and only patches the DOM nodes that actually changed — typically just the text of one error paragraph, or its insertion/removal — not the whole form.

Common Mistakes

1. Mutating the errors object instead of replacing it

State must be treated as immutable. Mutating it directly can silently fail to trigger a re-render, because React uses reference equality to decide whether state changed.

function handleBlur(e) {
  errors[e.target.name] = "Required";
  setErrors(errors);
}

Here, errors is mutated in place and then passed back to setErrors as the very same reference it already had — React may skip the re-render entirely, so the error never appears. Build a new object instead:

function handleBlur(e) {
  setErrors((prev) => ({ ...prev, [e.target.name]: "Required" }));
}

2. Forgetting e.preventDefault() in the submit handler

Without it, the browser performs its default full-page form submission (a GET/POST navigation) the instant the user clicks submit — wiping out your component’s state, including any errors you were about to compute, before React even gets to render them.

function handleSubmit(e) {
  const validationErrors = validate(values);
  setErrors(validationErrors);
}

The page reloads before the user ever sees the error. Always stop the native behavior first:

function handleSubmit(e) {
  e.preventDefault();
  const validationErrors = validate(values);
  setErrors(validationErrors);
}

3. A stale closure in a debounced validation effect

When you debounce validation with useEffect and setTimeout, an empty dependency array means the effect’s callback captures the values that existed on the very first render — forever.

useEffect(() => {
  const timer = setTimeout(() => {
    setErrors(validate(values));
  }, 500);
  return () => clearTimeout(timer);
}, []);

Every keystroke after the first render is validated against stale, outdated values from mount, because the effect never re-runs. Include every reactive value the effect reads:

useEffect(() => {
  const timer = setTimeout(() => {
    setErrors(validate(values));
  }, 500);
  return () => clearTimeout(timer);
}, [values]);

Best Practices

  • Keep validate(values) as a pure function with no side effects — it makes the logic easy to unit test in isolation, outside of React entirely.
  • Use a touched state (or validate only on blur/submit) so users aren’t shown errors for fields they haven’t reached yet.
  • Always call e.preventDefault() in your submit handler, and add noValidate to the <form> if you’re fully replacing the browser’s built-in validation UI with your own.
  • Give every error message a role="alert" (or associate it with the input via aria-describedby) so assistive technology announces it.
  • Compute the “next” value explicitly (e.g. { ...values, [name]: value }) rather than reading state right after setting it — state updates aren’t applied until the next render.
  • Extract shared validation logic into a custom hook (like useFormValidation) once you’re duplicating the same pattern across two or more forms.
  • For anything beyond a handful of fields or asynchronous rules (like checking username availability against a server), consider a dedicated library such as React Hook Form or Formik — the hand-rolled pattern here is the foundation those libraries build on.

Practice Exercises

  • Build a single-field form that validates a phone number (must be exactly 10 digits) on blur, showing “Phone number must be 10 digits.” when invalid.
  • Extend the two-field SignupForm example with a third field, confirmPassword, that must match password; make sure the error clears immediately once the two fields match, without waiting for another blur.
  • Modify the useFormValidation hook so it also returns a handleBlur function and a touched object, so errors only display for fields the user has actually visited, matching the behavior of Example 2.

Summary

  • React form validation is built from ordinary state: a values object, an errors object, and a pure validate(values) function — there’s no special validation API.
  • Decide when to validate: on submit (simplest), on blur (earlier feedback), or on change for fields already flagged invalid (so errors clear as the user fixes them).
  • Always compute the next values/errors explicitly rather than reading state you just set — state updates apply on the next render, not immediately.
  • Never mutate errors or values in place; always create a new object so React detects the change and re-renders.
  • Always call e.preventDefault() in the submit handler so the browser doesn’t reload the page before your validation runs.
  • Extract repeated validation logic into a custom hook like useFormValidation to keep individual form components small and consistent.