---
title: "Handle Email Validation Verdicts in a React Signup Form"
description: "Map deliverable, undeliverable, risky, and unknown into React UI states, show typo suggestions without rewriting the field, and fail open when the check cannot finish."
slug: "email-validation-verdicts-in-react-form"
date: 2026-09-22
updated: 2026-09-22
last_tested: 2026-09-22
summary: "Keep the API key on the server, call your own route from React, and branch the form on suggestion, reject, accept, and unchecked states instead of a single boolean."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "React — useState"
    url: "https://react.dev/reference/react/useState"
  - title: "MDN — Using the Fetch API"
    url: "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

A React signup form usually treats email validation as a boolean: the field is either valid or it shows a red error. That model throws away the useful part of a modern validator response. nobounce returns four verdicts, an optional `suggestion`, and a `checked.mx` flag that tells you whether DNS finished. The UI has to map those into states a user can act on — especially “Did you mean …?” — without turning a timeout into a blocked registration.

This article covers the browser side. The API key stays on the server. The React form talks only to your own route.

## Keep the key off the client

There is no free tier. Live DNS checks need a paid key — hobby is $1/mo for 1,000 checks, pro is $19/mo for 100,000, scale is $99/mo for 1,500,000 — obtained by redeeming an operator-minted access code or paying self-serve on the Stripe card rail or the usevig stablecoin rail.

That key belongs in a server environment variable and in an `Authorization` header on the outbound call. Do not put it in `NEXT_PUBLIC_*`, `VITE_*`, or any bundle shipped to the browser. The browser posts the typed address to `/api/validate-email`; that route calls `https://nobounce.dev/v1/check`. The server-side shape of that call is covered in [How to Validate an Email Address in Node.js](https://nobounce.dev/blog/validate-email-in-nodejs/).

## Shape the form state around actions, not booleans

```jsx
import { useState } from "react";

const INITIAL = {
  email: "",
  status: "idle", // idle | checking | suggest | reject | ready | unchecked
  suggestion: null,
  reason: null,
  mxChecked: null,
};

export function SignupEmailField({ onReady }) {
  const [state, setState] = useState(INITIAL);

  async function validate(email) {
    setState((s) => ({ ...s, status: "checking", suggestion: null, reason: null }));

    let payload;
    try {
      const response = await fetch("/api/validate-email", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
        signal: AbortSignal.timeout(2500),
      });
      if (!response.ok) {
        setState((s) => ({ ...s, status: "unchecked", mxChecked: false }));
        onReady?.({ email, verdict: "unchecked", mxChecked: false });
        return;
      }
      payload = await response.json();
    } catch {
      setState((s) => ({ ...s, status: "unchecked", mxChecked: false }));
      onReady?.({ email, verdict: "unchecked", mxChecked: false });
      return;
    }

    if (payload.action === "suggest") {
      setState((s) => ({
        ...s,
        status: "suggest",
        suggestion: payload.suggestion,
        reason: payload.reason,
      }));
      return;
    }

    if (payload.action === "reject") {
      setState((s) => ({ ...s, status: "reject", reason: payload.reason }));
      return;
    }

    setState((s) => ({
      ...s,
      status: "ready",
      reason: payload.reason ?? null,
      mxChecked: payload.mxChecked ?? true,
    }));
    onReady?.({
      email,
      verdict: payload.verdict ?? "deliverable",
      mxChecked: payload.mxChecked ?? true,
    });
  }

  // ...render below
}
```

Your API route should return the small action object from the Node helper (`suggest`, `reject`, `accept`, or a null-turned-`unchecked`), not the raw nobounce payload. That keeps React ignorant of transport details and makes the fail-open path explicit.

## Render four outcomes, not one error string

| form status | what the user sees | can submit? |
|---|---|---|
| `checking` | spinner or muted “Checking…” under the field | no |
| `suggest` | “Did you mean `user@gmail.com`?” with Accept / Keep typed | only after a choice |
| `reject` | reason-specific message, no correction available | no |
| `ready` | quiet success affordance, or nothing | yes |
| `unchecked` | nothing blocking; optionally a soft note | yes |

The suggestion branch is the product. Accepting it writes the suggested address into the controlled input and re-marks the field `ready`. Keeping the typed value also marks `ready`, but records that the user declined the correction — useful later if the message bounces. Do not silently rewrite the input: confidence is not certainty. The correction mechanics behind `suggestion` are covered in [Suggest the Correction Instead of Rejecting the Signup](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

```jsx
{state.status === "suggest" && state.suggestion && (
  <div role="status">
    <p>Did you mean <strong>{state.suggestion}</strong>?</p>
    <button
      type="button"
      onClick={() => {
        setState((s) => ({
          ...s,
          email: state.suggestion,
          status: "ready",
          suggestion: null,
        }));
        onReady?.({
          email: state.suggestion,
          verdict: "corrected",
          mxChecked: true,
        });
      }}
    >
      Use suggestion
    </button>
    <button
      type="button"
      onClick={() => {
        setState((s) => ({ ...s, status: "ready", suggestion: null }));
        onReady?.({
          email: state.email,
          verdict: "kept_typed",
          mxChecked: true,
        });
      }}
    >
      Keep what I typed
    </button>
  </div>
)}

{state.status === "reject" && (
  <p role="alert">That address cannot receive mail ({state.reason}).</p>
)}
```

`risky` should land in `ready`. Disposable domains and role accounts such as `admin@` can receive mail; blocking them is a product rule you apply deliberately, not a validity fact the form should hard-code. `unknown` with `checked.mx: false` is also `ready`/`unchecked` territory: the DNS lookup did not finish, so the honest UI is to let the user through and store that the check was degraded.

## Call on blur or submit, never on every keystroke

Wire validation to `onBlur` of the email input, or to the form’s `onSubmit` before you create the account. Live DNS on each keystroke burns quota on incomplete strings (`d`, `di`, `die…`) and races the controlled input against in-flight responses.

If you debounce blur for fast tabbing, cancel the previous `AbortController` when the value changes. Treat abort the same as timeout: set `unchecked` and allow submit. That is the same advisory rule described in [Fail Open: Email Validation Must Never Block a Signup](https://nobounce.dev/blog/fail-open-email-validation-signup/) — the validator may annotate the record; it may not be the reason registration fails when the network is the thing that broke.

## What the server must return to the form

Your `/api/validate-email` route should:

1. Read the email from the JSON body.
2. Call `POST https://nobounce.dev/v1/check` with `Authorization: Bearer $NOBOUNCE_KEY` and a ~2s timeout.
3. Map the verdict into `{ action, suggestion?, reason?, verdict?, mxChecked? }`.
4. On timeout, 5xx, or RFC 9457 problem details, return `{ action: "accept", verdict: "unchecked", mxChecked: false }` with HTTP 200 — not a 502 that the form then misreads as “reject”.

nobounce does not store a plaintext address; anything persisted server-side there is SHA-256, with domains in clear. Your own logs should follow the same rule: log `reason` and `checked.mx`, not the raw email.

`POST /demo/check` is useful while you are wiring the UI because it needs no key, but it is fixture-only. It resolves the frozen corpus from `GET https://nobounce.dev/v1/config` and cannot validate arbitrary live addresses. Point the React form at your authenticated server route before you ship.

## Verify the four branches before merge

Drive the form with known inputs (or stub the API route) and confirm:

- `user@gmai.com` → suggestion UI, not a hard error.
- `not-an-email` → reject with `syntax_invalid`.
- a normal deliverable address → submit enabled, no banner.
- an aborted or timed-out request → submit still enabled, `mxChecked: false` recorded.

A happy-path screenshot proves the API works. The timeout case proves the signup survives when it does not.

For the ordered key-acquisition, verdict-mapping, and verification procedure, give your coding agent [/integrate.md](https://nobounce.dev/integrate.md).
