Skip to content

Real-time email feedback in a React signup form

· 8 min read · Tutorials

Real-time email feedback in a React signup form works best when you check the address on blur, not on every keystroke, call your own backend (which holds the API key) instead of the lookup API directly, and let the user continue if the check fails. Show a specific message for each problem: a "did you mean" fix for typos, a clear refusal for disposable inboxes, and a soft hint for shared addresses like info@. Enforce the same rules on the server at submit, because client checks are only advice.

The code uses React 19 and a small Node endpoint that calls pingutil. The UX rules carry over to any framework.

The UX rules first

  1. Validate on blur. Someone typing jane@gm hasn't made a mistake yet. Wait until they leave the field.
  2. Once a message is showing, re-check as they type (debounced). If you told them gmial.com looks wrong, the message should disappear when they fix it, without another blur. A 500 ms debounce avoids a request per keystroke.
  3. Never block on network failure. If your endpoint is slow, down or rate limited, show nothing and let them submit. The server decides.
  4. Don't disable the submit button based on client state. Client state can be stale. Let them submit and show the server's answer.
  5. Don't accuse. A randomized-looking local part (xk7q2m@) is a fraud signal, but real people have odd handles too. Handle it server side, silently.

Message wording

Situation Signal Severity Message
Common domain typo local check (gmial.com) fixable "Did you mean jane@gmail.com?" with a one-click fix
Domain doesn't exist domain_signals.domain_exists === false warning "We couldn't find the domain acme.con. Check for a typo."
Domain can't receive mail domain_signals.can_receive_email === false warning "acme.io doesn't seem to accept email, so you might not get our confirmation."
Disposable inbox verdict.reason === 'disposable_domain' blocking "Addresses at mailinator.com are temporary, so you'd lose access to your account. Please use an address you'll keep."
Role or system address verdict.reason is role_based or system_generated hint "Shared inboxes like info@ work, but anyone with access can reset your password. A personal address is safer."
Randomized local part verdict.reason === 'randomized_local_part' none in UI Flag server side for review

The disposable message names the user's own downside, which reads better than "not allowed". Whether to block at all is a product call; see block or flag disposable emails.

Step 1: the backend proxy

The browser never sees your pingutil key. It calls /api/email-check, which calls the lookup API with skip_enrichment: true (no company enrichment, so it responds in milliseconds) and check_dns: true (adds domain_signals), then returns a small, UI-shaped result.

// server/email-check.ts (Express, Node 18+)
import express from 'express';

type CheckResult =
  | { status: 'ok' | 'unknown' }
  | { status: 'block' | 'warn'; reason: string; domain?: string };

const cache = new Map<string, { at: number; result: CheckResult }>();
const TTL_MS = 10 * 60 * 1000;

export async function checkEmail(email: string): Promise<CheckResult> {
  const hit = cache.get(email);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.result;

  let result: CheckResult;
  try {
    const res = await fetch('https://pingutil.com/v1/lookup', {
      method: 'POST',
      headers: {
        authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ input: email, skip_enrichment: true, check_dns: true }),
      signal: AbortSignal.timeout(2500),
    });
    if (res.status === 400) return { status: 'block', reason: 'invalid' };
    if (!res.ok) return { status: 'unknown' }; // 429, 5xx: fail open, don't cache

    const d = await res.json();
    const domain = d.domain_unicode;
    const ds = d.domain_signals;
    if (d.verdict.reason === 'disposable_domain') result = { status: 'block', reason: 'disposable', domain };
    else if (ds && !ds.domain_exists) result = { status: 'warn', reason: 'no_domain', domain };
    else if (ds && !ds.can_receive_email) result = { status: 'warn', reason: 'no_mail', domain };
    else if (d.verdict.reason === 'role_based' || d.verdict.reason === 'system_generated')
      result = { status: 'warn', reason: 'role' };
    else result = { status: 'ok' }; // includes randomized_local_part: handled at signup, not in the UI
  } catch {
    return { status: 'unknown' }; // timeout or network error
  }
  cache.set(email, { at: Date.now(), result });
  return result;
}

export const router = express.Router();
router.post('/api/email-check', express.json(), async (req, res) => {
  const email = String(req.body?.email ?? '').trim().toLowerCase().slice(0, 320);
  res.json(await checkEmail(email));
});

Two things to add before production:

  • Rate-limit this endpoint per IP. It's unauthenticated and spends your key's 60 requests/minute. Without a limit, one script can exhaust it and every real user falls through to unknown. Any standard per-IP limiter in front of the route is enough.
  • Know your budget. The 60/min key limit is shared by every visitor, which is why 429 maps to unknown (fail open) and why the cache exists.

Your signup handler should call the same checkEmail() on submit, reject block, and record randomized_local_part verdicts for review. The server-side half is covered in more depth in the Next.js server action guide.

Step 2: the client hook

The typo check runs locally so it feels instant. Keep the list short: misspelled webmail domains and fat-fingered TLDs.

// useEmailCheck.ts
import { useRef, useState } from 'react';

export type Check =
  | { status: 'idle' | 'ok' | 'unknown' }
  | { status: 'block' | 'warn'; reason: string; domain?: string; suggestion?: string };

const TYPOS: Record<string, string> = {
  'gmial.com': 'gmail.com', 'gamil.com': 'gmail.com', 'gmai.com': 'gmail.com', 'gnail.com': 'gmail.com',
  'hotmial.com': 'hotmail.com', 'hotmai.com': 'hotmail.com', 'outlok.com': 'outlook.com',
  'yaho.com': 'yahoo.com', 'yahooo.com': 'yahoo.com', 'icloud.co': 'icloud.com',
};
const SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

function suggestFix(email: string): string | null {
  const [local, domain] = email.split('@');
  if (TYPOS[domain]) return `${local}@${TYPOS[domain]}`;
  const tld = domain.match(/\.(con|cmo|vom|comm)$/);
  if (tld) return `${local}@${domain.slice(0, -tld[0].length)}.com`;
  return null;
}

export function useEmailCheck() {
  const [check, setCheck] = useState<Check>({ status: 'idle' });
  const inflight = useRef<AbortController | null>(null);
  const debounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  async function run(raw: string) {
    clearTimeout(debounce.current);
    inflight.current?.abort();
    const email = raw.trim().toLowerCase();
    if (!SHAPE.test(email)) return setCheck({ status: 'idle' }); // let required/type=email handle it

    const fix = suggestFix(email);
    if (fix) return setCheck({ status: 'warn', reason: 'typo', suggestion: fix });

    const ctrl = new AbortController();
    inflight.current = ctrl;
    const timer = setTimeout(() => ctrl.abort(), 3000);
    try {
      const res = await fetch('/api/email-check', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ email }),
        signal: ctrl.signal,
      });
      if (!res.ok) throw new Error(String(res.status));
      if (inflight.current === ctrl) setCheck(await res.json());
    } catch {
      if (inflight.current === ctrl) setCheck({ status: 'unknown' }); // never block on failure
    } finally {
      clearTimeout(timer);
    }
  }

  return {
    check,
    onBlur: (value: string) => run(value),
    onChange: (value: string) => {
      if (check.status === 'block' || check.status === 'warn') {
        clearTimeout(debounce.current);
        debounce.current = setTimeout(() => run(value), 500);
      }
    },
    recheck: run,
  };
}

The inflight.current === ctrl guard drops stale responses, so only the latest check wins.

Step 3: the form

// SignupForm.tsx
import { useActionState, useRef } from 'react';
import { useEmailCheck, type Check } from './useEmailCheck';

type FormState = { error?: string; email?: string; ok?: boolean };

async function signup(_prev: FormState, formData: FormData): Promise<FormState> {
  const email = String(formData.get('email') ?? '');
  try {
    const res = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email, password: formData.get('password') }),
    });
    if (res.ok) return { ok: true };
    const body = await res.json().catch(() => ({}));
    return { email, error: body.message ?? 'Something went wrong. Please try again.' };
  } catch {
    return { email, error: 'We could not reach the server. Check your connection and try again.' };
  }
}

function message(c: Check): string | null {
  if (c.status !== 'block' && c.status !== 'warn') return null;
  switch (c.reason) {
    case 'typo': return `Did you mean ${c.suggestion}?`;
    case 'invalid': return "That doesn't look like a complete email address.";
    case 'disposable': return `Addresses at ${c.domain} are temporary, so you'd lose access to your account. Please use an address you'll keep.`;
    case 'no_domain': return `We couldn't find the domain ${c.domain}. Check for a typo.`;
    case 'no_mail': return `${c.domain} doesn't seem to accept email, so you might not get our confirmation.`;
    case 'role': return 'Shared inboxes like info@ work, but anyone with access can reset your password. A personal address is safer.';
    default: return null;
  }
}

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(signup, {});
  const { check, onBlur, onChange, recheck } = useEmailCheck();
  const input = useRef<HTMLInputElement>(null);
  const hint = message(check);

  if (state.ok) return <p>Check your inbox to confirm your address.</p>;

  return (
    <form action={formAction}>
      <label htmlFor="email">Work or personal email</label>
      <input
        ref={input}
        id="email"
        name="email"
        type="email"
        autoComplete="email"
        required
        defaultValue={state.email}
        aria-invalid={check.status === 'block' || undefined}
        aria-describedby="email-hint"
        onBlur={(e) => onBlur(e.currentTarget.value)}
        onChange={(e) => onChange(e.currentTarget.value)}
      />
      <p id="email-hint" aria-live="polite" className={check.status === 'block' ? 'error' : 'hint'}>
        {hint}
        {check.status === 'warn' && check.reason === 'typo' && (
          <button
            type="button"
            onClick={() => {
              if (!input.current || !check.suggestion) return;
              input.current.value = check.suggestion;
              recheck(check.suggestion);
            }}
          >
            Use this
          </button>
        )}
      </p>

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" autoComplete="new-password" required />

      {state.error && <p role="alert">{state.error}</p>}
      <button type="submit" disabled={isPending}>{isPending ? 'Creating account…' : 'Create account'}</button>
    </form>
  );
}

Notes on the React 19 parts:

  • useActionState returns [state, formAction, isPending], and when you pass the action to <form action>, React runs it in a transition and hands it the FormData (react.dev reference). isPending drives the button label, so you don't need a separate loading flag.
  • React resets uncontrolled fields after a form action. Returning email in the error state and feeding it back as defaultValue means a server-side rejection doesn't wipe what they typed. (The password field clears, which is what most users expect anyway.)
  • aria-live="polite" announces the hint to screen readers when it appears, without stealing focus. aria-invalid is set only for blocking problems, not hints.

What this won't catch

  • A typo in the local part. jnae@gmail.com is a valid, deliverable-looking address. Only a confirmation email catches that, so send one.
  • New disposable domains. The blocklist behind disposable_domain is updated weekly from a community list. A domain registered this morning will pass until it's added.
  • Typos that are real domains. If a misspelling is a registered domain with MX records, DNS checks pass it. The local typo map is the only defense, which is why it's worth keeping.

Field reference for the proxy: API docs. For why a regex alone doesn't get you far, see why an email regex is never enough; for when a shared inbox is fine, see role-based email addresses.

Questions people ask

Should I validate the email on every keystroke?
No. Telling someone their address is invalid while they are still typing it is noise. Check when the field loses focus, and only re-check while typing if an error is already showing, so the message clears as soon as they fix it.
Can I call the email lookup API directly from the browser?
Not with your real API key. Anything shipped to the browser is public, and anyone could copy the key and spend your rate limit. Put a small endpoint on your backend that holds the key, calls the API and returns only what the form needs.
What should the form do if the check fails or times out?
Let the user continue. The check is a convenience, not a gate. Your server re-checks on submit, and if that also fails, accept the signup and flag it for later rather than locking out a real user because a third-party call was slow.