DEV Community

ryanlee
ryanlee

Posted on

React Email Verification: Design a Safe Retry Button

Email verification looks small in a signup flow, but the retry button can turn a calm React screen into a race-condition factory. Here is a practical state model for safer retries, clearer UX, and tests that cover the awkward paths.

The retry button is a product decision

Most teams start with a boolean such as isSending. It works until a user clicks Resend email, changes tabs, returns to the page, and clicks again before the first request finishes. Now the interface has to answer several questions:

  • Is the previous request still active?
  • Did the server accept one request or two?
  • Should the countdown restart?
  • What should happen if the user enters a different email in another tab?

These are product decisions, not only React details. A useful email flow should make progress visible without promising that delivery is instant. This matters whether the test data comes from a burner email generator, a real inbox, or a temporary email address used for a disposable test account.

The fix is not complicated, but it is easy to miss a few edge case. Start by naming the states instead of letting unrelated booleans describe them.

Model the flow with explicit states

TypeScript gives this flow a readable contract. A discriminated union prevents impossible combinations such as status: "success" with an old error message still displayed.

type VerificationState =
  | { status: "idle"; email: string }
  | { status: "sending"; email: string; requestId: string }
  | { status: "sent"; email: string; expiresAt: number }
  | { status: "failed"; email: string; message: string };
Enter fullscreen mode Exit fullscreen mode

The resend action should move from sent or failed to sending, and the response should update the state only when it belongs to the current email and request. A small request id is often enough for this client-side guard:

async function resend(current: VerificationState) {
  if (current.status === "sending") return;

  const requestId = crypto.randomUUID();
  setState({ status: "sending", email: current.email, requestId });

  try {
    await api.post("/verification/resend", {
      email: current.email,
      requestId,
    });

    setState((latest) =>
      latest.status === "sending" && latest.requestId === requestId
        ? { status: "sent", email: current.email, expiresAt: Date.now() + 60_000 }
        : latest,
    );
  } catch {
    setState((latest) =>
      latest.status === "sending" && latest.requestId === requestId
        ? { status: "failed", email: current.email, message: "Try again in a moment." }
        : latest,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This guard prevents a slow response from overwriting a newer state. It does not replace server-side protection, though. The endpoint should apply a rate limit and treat the request id as an idempotency key, so a double click cannot create two confusing delivery events.

Keep retries safe on the client and server

Client state is for good feedback; the server is the source of truth. For every resend request, store or derive enough information to enforce a short cooldown:

  1. Normalize the email before creating the idempotency key.
  2. Associate the key with the signup session, not only the address.
  3. Return the same result for a repeated key during its retention window.
  4. Avoid putting the verification token or full email in application logs.

The response can stay intentionally boring: accepted, cooldown active, or invalid session. The client can then show a timer without guessing whether a message was delivered.

This is also where test fixtures need a privacy boundary. A temp email generator may be useful for a disposable inbox in a development scenario, but it should never receive production customer data. Keep fixture addresses, credentials, and delivery logs in the test environment. Search terms such as fake e mail com can appear in test notes, but they should not become an accidental production dependency.

For CI, keep the evidence small and reviewable. A receipt containing the request id, normalized outcome, and elapsed time is more useful than dumping the complete email body. The ideas in these reviewable automation receipts and focused CI email checks translate well to signup tests.

Make the UI accessible while waiting

The button should communicate why it is disabled and when it becomes available again. Avoid changing its width as the label moves between “Resend email”, “Sending…”, and “Try again”. A layout shift makes the screen feel less reliable than the network actually is.

const waiting = state.status === "sending";
const cooldown = state.status === "sent" && state.expiresAt > Date.now();

<button
  type="button"
  disabled={waiting || cooldown}
  aria-describedby="verification-help"
  onClick={() => resend(state)}
>
  {waiting ? "Sending…" : cooldown ? "Email sent" : "Resend email"}
</button>
<p id="verification-help" role="status" aria-live="polite">
  {state.status === "failed" ? state.message : "Check your inbox, then try again."}
</p>
Enter fullscreen mode Exit fullscreen mode

The exact countdown can be rendered beside the button, but do not make it the only feedback. Screen reader users need a status update too, and keyboard users need the focus to remain predictable. It is a small detail, yet it save a lot of confusion during signup.

Test the awkward paths

The happy path is only one test. With React Testing Library and a mocked API, cover at least these cases:

  • A second click while sending does not call the API again.
  • A delayed first response cannot overwrite a later email state.
  • A rejected request exposes a useful retry message.
  • A cooldown disables the button without hiding the reason.
  • A successful resend announces a status update to assistive technology.
  • A repeated request id is handled as one server-side event.

Use deferred promises in the test to control response order. It is much easier to prove the race condition when the first promise can be held open, instead of hoping a fast test runner happens to reproduce it. Also test a refresh or tab return if your flow persists state in storage; stale “sent” state should not block a user forever.

A small checklist before shipping

Before calling the flow done, check:

  • Does the UI have named states rather than a pile of booleans?
  • Does the server enforce idempotency and rate limits?
  • Can a late response be ignored safely?
  • Is the retry message clear in both visual and assistive output?
  • Do tests control delayed responses and duplicate clicks?
  • Are temporary inbox fixtures isolated from production data?

An email verification retry is a tiny interaction with a surprising amount of trust attached to it. Make the state explicit, let the server own deduplication, and leave a short receipt for failures. The result feels faster because users always know what happend next.

Top comments (0)