DEV Community

ryanlee
ryanlee

Posted on

React Email Verification States That Explain Themselves

Email verification looks like a small form until you ship it. Then the edge cases arrive: the request is still running, the code is wrong, the link expired, the user clicked resend twice, or the browser came back from a suspended tab.

The UI problem is usually an unclear state model

Many React forms start with three booleans:

const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [verified, setVerified] = useState(false);
Enter fullscreen mode Exit fullscreen mode

This is friendly at first, but it can describe impossible combinations. What does loading: true and verified: true mean? Should an old error remain visible while a new request is running? Can the resend button be enabled during a code submission?

The important part are the transitions, not the number of variables. A verification flow should tell the user what is happening and what action is safe next.

Model verification as a small state machine

Start with named states that match product behavior. A TypeScript discriminated union makes the rules visible in the component:

type VerificationState =
  | { kind: "idle" }
  | { kind: "sending" }
  | { kind: "awaiting-code"; expiresAt: number; resendAvailableAt: number }
  | { kind: "verifying"; code: string }
  | { kind: "verified" }
  | { kind: "error"; message: string; canRetry: boolean }
  | { kind: "expired" };
Enter fullscreen mode Exit fullscreen mode

Now the UI has a smaller decision surface. An awaiting-code state must carry the timing information needed by the countdown. A verified state does not need an error message. The compiler helps keep these informations together when the flow changes later.

I like this approach because it connects product language to implementation language. “Check your inbox” becomes awaiting-code, while “Your code expired” becomes expired. This makes a confusing email flow a little more clear for both the user and the next developer.

Render states with a discriminated union

Keep rendering close to the state model, but keep side effects in event handlers or a dedicated hook:

function VerificationPanel({ state }: { state: VerificationState }) {
  switch (state.kind) {
    case "idle":
      return <button type="button">Send verification email</button>;
    case "sending":
      return <p role="status">Sending your verification email…</p>;
    case "awaiting-code":
      return <CodeForm expiresAt={state.expiresAt} />;
    case "verifying":
      return <p role="status">Checking the code…</p>;
    case "verified":
      return <p role="status">Email verified. You can continue.</p>;
    case "expired":
      return <button type="button">Send a new code</button>;
    case "error":
      return (
        <div role="alert">
          <p>{state.message}</p>
          {state.canRetry && <button type="button">Try again</button>}
        </div>
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

The UI should not guess from a partially updated collection of flags. It should announce the current state, disable actions that cannot succeed, and preserve focus when an error replaces the form. A status message that appears only visually is not enough; screen readers need a useful announcement too.

Keep resend and expiration explicit

Resend is where many otherwise clean implementations become flaky. Treat it as a separate transition with a server-side policy behind it. The client countdown is useful feedback, but it is not the source of truth.

When the user submits a code, capture the request identity. If an older request finishes after a newer one, ignore the stale result. A simple sequence number works for a small hook:

const requestId = useRef(0);

async function verify(code: string) {
  const currentId = ++requestId.current;
  setState({ kind: "verifying", code });

  const result = await verifyCode(code);
  if (currentId !== requestId.current) return;

  setState(result.ok
    ? { kind: "verified" }
    : { kind: "error", message: "That code is not valid.", canRetry: true });
}
Enter fullscreen mode Exit fullscreen mode

This small guard prevents a slow response from moving a fresh form backwards. It also make the failure behavior much easier to explain in a test.

For temporary test inboxes, a service such as tempmailso can help isolate verification data during development. Do not use an inbox you do not control for real customer accounts, and do not treat a successful test message as proof that production delivery is healthy.

Test the transitions, not just the happy path

Component tests should cover state changes and user-visible consequences:

  1. Sending starts with one click and announces progress.
  2. A successful response renders the code form.
  3. An invalid code keeps the user in a recoverable error state.
  4. A stale response cannot overwrite a newer request.
  5. Resend is unavailable during its cooldown and becomes available afterward.
  6. Expiration offers a clear next action.

This is also where connecting one alert to one rollout is a useful operational idea: make each failure point explainable instead of collapsing several causes into “Something went wrong.” For account flows, proof-first email change flows offer the same mindset from a security angle.

During manual testing, try a tem email, paste a code twice, navigate away while the request is pending, and return after the expiry time. These cases finds the gaps that a single successful test will miss. They are boring checks, but boring checks is exactly what protects a sign-up flow from surprise.

A short shipping checklist

Before shipping, check that:

  • Every visible message maps to one explicit state.
  • Submit, resend, and continue actions have clear disabled rules.
  • Stale responses cannot overwrite current state.
  • Expiration and retry behavior are tested.
  • Status and error messages work with keyboard and screen readers.
  • The server still enforces verification expiry and resend limits.

An explicit state machine does not make email delivery perfect. It does make the product honest about what it knows, what it is waiting for, and what the user can do next. That clarity is a feature worth shipping.

Top comments (0)