All posts

Email Validation in a React Form: Timing, Typos and Accessibility

··7 min read

To validate email in a React form, let <input type="email"> do the syntax check and read the result from input.validity through a ref. Show errors on blur, not on every keystroke. Offer a "did you mean" fix for common domain typos, then ask your server whether the domain accepts mail, and let the form submit if that request fails. Repeat every check on the server.

The component below was built with React 19.3.0 and tested in headless Chrome on 6 October 2026. It works with any React setup, since it only needs one small JSON endpoint on the server. For the Next.js Server Action side, see verifying email on signup in Next.js.

Let the browser do syntax

type="email" already applies a sensible, linear-time pattern, and ValidityState tells you exactly which rule failed.

The HTML standard defines both the pattern and a value sanitization step: "Strip newlines from the value, then strip leading and trailing ASCII whitespace from the value." That matters, because a pasted address with a trailing space is the most common way a correct address fails a hand-written check. When we filled the field with " jane@gmail.com ", the browser reported the value as jane@gmail.com.

Two ValidityState flags cover email:

Flag Meaning Message to show
valueMissing required and empty "Enter your email address."
typeMismatch Not a valid email address per the HTML pattern "Include an @ and a domain, like name@example.com."

Avoid writing your own regex on top. We tested five common email regexes and found ones that reject plus signs, reject every TLD longer than three letters, or take seconds to reject a 33-character string.

Show errors at the right moment

Validate when the field loses focus, then clear the error as soon as the user edits.

An error that shows up while someone is halfway through typing jane@ is noise. An error that stays on screen while they fix the problem is worse. The rule that works:

  1. Nothing shows until the first blur (tracked as touched).
  2. On blur, run the checks and show the result.
  3. On any change after that, clear the message and wait for the next blur.

Submission is the other moment to validate. With noValidate on the <form>, the browser won't show its own tooltip, so your submit handler (or your server) needs to run the same checks.

Typo suggestions that don't break real domains

Suggest a correction for near-misses of a few large providers, and keep an allowlist of real domains that sit one edit away.

A plain edit-distance check will happily "correct" mail.com to gmail.com, and mail.com is a real provider. So the component has two lists: domains it will suggest (SUGGEST) and domains it will never touch (KNOWN). The suggestion is an offer the user clicks, never an automatic rewrite. The data behind which typos are common is in catching email typos at signup.

The component

One uncontrolled input, a ref for ValidityState, and a small state machine for feedback.

"use client";

import { useRef, useState } from "react";

const SUGGEST = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com"];
// Real domains one or two edits away from a SUGGEST entry. Never "correct" these.
const KNOWN = new Set([...SUGGEST, "mail.com", "ymail.com", "gmx.com", "live.com", "me.com", "mac.com"]);

type Feedback =
  | { kind: "none" }
  | { kind: "error"; message: string }
  | { kind: "suggest"; suggestion: string };

function distance(a: string, b: string): number {
  const row = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    let prev = row[0];
    row[0] = i;
    for (let j = 1; j <= b.length; j++) {
      const temp = row[j];
      row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
      prev = temp;
    }
  }
  return row[b.length];
}

function suggestDomain(email: string): string | null {
  const at = email.lastIndexOf("@");
  if (at < 1) return null;
  const domain = email.slice(at + 1).toLowerCase();
  if (KNOWN.has(domain)) return null;
  const match = SUGGEST.find((d) => distance(domain, d) <= 2);
  return match ? `${email.slice(0, at)}@${match}` : null;
}

function syntaxMessage(input: HTMLInputElement): string | null {
  if (input.validity.valueMissing) return "Enter your email address.";
  if (input.validity.typeMismatch) return "Include an @ and a domain, like name@example.com.";
  return null;
}

export function EmailField() {
  const inputRef = useRef<HTMLInputElement>(null);
  const requestRef = useRef<AbortController | null>(null);
  const [feedback, setFeedback] = useState<Feedback>({ kind: "none" });
  const [touched, setTouched] = useState(false);

  async function checkOnServer(email: string) {
    requestRef.current?.abort();
    const controller = new AbortController();
    requestRef.current = controller;
    const timeout = setTimeout(() => controller.abort(), 3000);
    try {
      const res = await fetch("/api/check-email", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
        signal: controller.signal,
      });
      if (!res.ok) return;
      const data: { status: string; reason?: string } = await res.json();
      if (data.status === "invalid" || data.status === "no-mail") {
        setFeedback({ kind: "error", message: data.reason ?? "This address can't receive email." });
      }
    } catch {
      // Network error, timeout or a newer request: say nothing and let the form submit.
    } finally {
      clearTimeout(timeout);
    }
  }

  function validate() {
    const input = inputRef.current;
    if (!input) return;
    input.value = input.value.trim();

    const message = syntaxMessage(input);
    if (message) {
      setFeedback({ kind: "error", message });
      return;
    }
    const suggestion = suggestDomain(input.value);
    if (suggestion) {
      setFeedback({ kind: "suggest", suggestion });
      return;
    }
    setFeedback({ kind: "none" });
    void checkOnServer(input.value);
  }

  function acceptSuggestion(value: string) {
    if (inputRef.current) inputRef.current.value = value;
    setFeedback({ kind: "none" });
    inputRef.current?.focus();
  }

  const showError = touched && feedback.kind === "error";

  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        ref={inputRef}
        id="email"
        name="email"
        type="email"
        autoComplete="email"
        spellCheck={false}
        required
        aria-invalid={showError || undefined}
        aria-describedby="email-feedback"
        onBlur={() => {
          setTouched(true);
          validate();
        }}
        onChange={() => {
          if (touched) setFeedback({ kind: "none" });
        }}
      />
      <div id="email-feedback" aria-live="polite">
        {showError && <p>{feedback.message}</p>}
        {feedback.kind === "suggest" && (
          <p>
            Did you mean{" "}
            <button type="button" onClick={() => acceptSuggestion(feedback.suggestion)}>
              {feedback.suggestion}
            </button>
            ?
          </p>
        )}
      </div>
    </div>
  );
}

A few choices worth explaining:

  • Uncontrolled input. The browser owns the value, so ValidityState is always current and the field works inside a React 19 <form action> without extra wiring.
  • The suggestion button is type="button". Without that, clicking it would submit the form.
  • The server request is cancellable. If the user blurs twice, the first request is aborted, so a slow early answer can't overwrite a newer one. It also gives up after three seconds.
  • Failures are silent. If the endpoint is down, the user sees nothing and can submit. The server-side check at submit time is the one that decides.

The server endpoint

The endpoint runs the same syntax and MX check you use at submit time, and returns only what the UI needs.

Any backend works. In a Next.js Route Handler, using the checkEmail function from the Next.js post, it is a few lines:

// app/api/check-email/route.ts
import { checkEmail } from "@/app/lib/check-email";

export async function POST(request: Request) {
  const body = await request.json().catch(() => ({}));
  const email = typeof body.email === "string" ? body.email.slice(0, 254) : "";
  const result = await checkEmail(email);
  return Response.json(
    result.status === "invalid" || result.status === "no-mail"
      ? { status: result.status, reason: result.reason }
      : { status: result.status },
  );
}

This endpoint is public, so rate-limit it per IP. Otherwise anyone can use it as a free DNS-checking service. If you call a paid verification API behind it, the rate limit protects your bill too: handling verification API rate limits covers the client-side patterns.

Accessibility checklist

Errors must be in text, tied to the field, and announced.

WCAG 2.2 success criterion 3.3.1 says: "If an input error is automatically detected, the item that is in error is identified and the error is described to the user in text." In the component, that maps to:

  • aria-describedby="email-feedback" connects the message to the input.
  • aria-invalid is set only while an error is showing.
  • The feedback container is aria-live="polite", so the message is announced without stealing focus.
  • autoComplete="email" lets browsers and password managers fill the field, which avoids typos altogether.
  • Messages say how to fix the problem ("Include an @ and a domain"), not just that something is wrong.

What the test returned

Each value was typed into the field in headless Chrome, then focus was moved away:

Typed Feedback
(empty) "Enter your email address."
jane "Include an @ and a domain, like name@example.com."
jane@gmial.com "Did you mean jane@gmail.com?" Clicking it replaced the value
jane@mail.com Nothing: a real domain on the KNOWN list
jane@example.com "example.com does not accept email." (null MX, from the server)
" jane@gmail.com " Nothing. Value read back as jane@gmail.com

The takeaway

  • Use type="email" and ValidityState. Don't add a regex on top.
  • Validate on blur, clear on change, and validate again on submit and on the server.
  • Offer typo fixes as a clickable suggestion, with an allowlist of real look-alike domains.
  • Make the server check cancellable, time-limited and fail-open.
  • None of this proves the mailbox exists. To see what a full check says about one address, use the free email verifier.

Common questions

How do I validate an email input in React?

Use input type=email and read the browser's ValidityState through a ref: validity.valueMissing and validity.typeMismatch tell you what is wrong. Show the message when the field loses focus, not on every keystroke, and repeat the check on the server, because client-side validation can be skipped.

When should a React form show an email error?

After the user leaves the field (on blur) or submits, and never while they are still typing a correct address. Once an error is showing, clear it as soon as they edit the field, so the message doesn't sit there contradicting what they are fixing.

Do I need a regex to validate email in React?

No. Browsers already apply the WHATWG email pattern to input type=email and expose the result as validity.typeMismatch. A hand-written regex usually rejects valid addresses such as ones with a plus sign or a long TLD, and some patterns backtrack badly on long input.

How do I make email error messages accessible?

Tie the message to the input with aria-describedby, set aria-invalid when there is an error, and put the message inside an aria-live region so screen readers announce it. WCAG 3.3.1 requires that a detected input error be identified and described to the user in text.

Check your list before you send

Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.