Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

React Email Verification as a State Machine

Email verification often starts as one boolean: isLoading. Then product requirements arrive. The user can resend the message, the old link can expire, the request can fail, and a second click can race the first request.

Email verification often starts as one boolean: isLoading. Then product requirements arrive. The user can resend the message, the old link can expire, the request can fail, and a second click can race the first request.

At that point, a boolean no longer describes what the UI actually knows. A signup screen needs a small model of reality. In this post, I’ll show a practical React and TypeScript approach that keeps verification states explicit, makes retry behavior easier to reason about, and gives tests something meaningful to assert.

Why a boolean is not enough

Consider a component with isLoading and isVerified. What should it render when the first request failed but the resend request is running? What if the user opens an expired link while a new email is being sent? Two booleans allow combinations that may not make sense, such as β€œverified and waiting for a resend.”

This is a product problem as much as a code problem. Users need to know whether they should wait, check their inbox, try again, or start over. A good model makes those choices visible in the interface.

It also helps the backend boundary stay clear. For example, idempotent signup email handling belongs in the API contract, while the React component should focus on displaying the current result and choosing the next allowed action.

Define the verification states

Start with a discriminated union. Each state has a status field, so TypeScript can narrow the data available to the renderer.

type VerificationState =
  | { status: "idle" }
  | { status: "sending"; requestId: string }
  | { status: "sent"; sentAt: number }
  | { status: "verified"; verifiedAt: number }
  | { status: "expired"; expiredAt: number }
  | { status: "error"; message: string; canRetry: boolean };

The names are more important than the exact list. sent means the request completed, not that the person has clicked the link. expired means the app has evidence that the token is no longer usable. error carries an action hint instead of forcing the UI to guess.

Keep request identity in the state when concurrent requests are possible. A late response should not overwrite a newer attempt. In a busy form, this detail matter more than it first appears.

Make transitions explicit in TypeScript

A reducer is a compact way to document which events are legal. It also prevents click handlers from quietly inventing a seventh state in the component.

type VerificationEvent =
  | { type: "SEND_STARTED"; requestId: string }
  | { type: "SEND_SUCCEEDED"; sentAt: number; requestId: string }
  | { type: "VERIFIED"; verifiedAt: number }
  | { type: "EXPIRED"; expiredAt: number }
  | { type: "FAILED"; message: string };

function verificationReducer(
  state: VerificationState,
  event: VerificationEvent,
): VerificationState {
  switch (event.type) {
    case "SEND_STARTED":
      return { status: "sending", requestId: event.requestId };
    case "SEND_SUCCEEDED":
      if (state.status !== "sending" || state.requestId !== event.requestId) {
        return state;
      }
      return { status: "sent", sentAt: event.sentAt };
    case "VERIFIED":
      return { status: "verified", verifiedAt: event.verifiedAt };
    case "EXPIRED":
      return { status: "expired", expiredAt: event.expiredAt };
    case "FAILED":
      return { status: "error", message: event.message, canRetry: true };
  }
}

The request ID check is a small but valuable guard. If a user clicks resend twice, the first response cannot move the screen back to an older sent result after the second request has started. Its easy to miss this race when the happy path is the only path being tested.

For larger applications, pair this reducer with an API response that distinguishes invalid, expired, and already-used tokens. A generic HTTP 400 does not give the UI enough information to help the user.

Render each state without race conditions

The component can now map state to actions directly:

function VerificationPanel({ state }: { state: VerificationState }) {
  switch (state.status) {
    case "idle":
      return <button>Send verification email</button>;
    case "sending":
      return <p aria-live="polite">Sending a fresh email…</p>;
    case "sent":
      return <p aria-live="polite">Check your inbox for the verification link.</p>;
    case "verified":
      return <p role="status">Your email is verified.</p>;
    case "expired":
      return <button>Send a new link</button>;
    case "error":
      return (
        <div role="alert">
          <p>{state.message}</p>
          {state.canRetry && <button>Try again</button>}
        </div>
      );
  }
}

Keep the copy action-oriented. β€œEmail failed” is less useful than β€œWe couldn’t send the email. Try again.” Also preserve focus when the state changes; replacing a button with a paragraph can leave keyboard users in an odd place. An aria-live region should announce status changes without making the whole form jump around.

For an isolated manual check, a disposable email generator can keep a temporary inbox outside a personal account. In automated checks, prefer controlled fixtures and a clear cleanup policy. API email fixture patterns are a useful companion when the test needs repeatable message data.

Test the edges, not just the happy path

The reducer is easy to test without rendering a browser. Cover at least these transitions:

  • idle to sending when a request starts.
  • sending to sent only when the request ID still matches.
  • sending to error when the API fails.
  • sent to verified after a valid callback.
  • Any link attempt to expired when the token is too old.
  • A second resend that ignores the first request’s late response.

You can also test the copy and keyboard behavior with React Testing Library. Assert that the retry button appears after a recoverable error, and that an expired token gives the user a fresh action instead of a dead-end message. It is a little more safer to assert behavior than CSS classes, because the product intent survives a visual refactor.

When naming test fixtures, teams sometimes inherit strings such as temp gamil com or tempail mail. Keep those values as plain fixture data if they are needed for compatibility, but do not let them become production validation rules. A typo in test input should not become a typo in the user experience.

Q&A: practical questions about retries

Should resend always be available?

Usually, no. Disable it while a request is in flight and consider a short cooldown after success. The exact cooldown is a product decision, but the state model should make it possible to enforce one.

Is a state machine overkill for one form?

Not when the form has more than two meaningful outcomes. The union can stay local to the feature, and the explicit cases often reduce debugging time even in a small app.

Where should expiry be calculated?

The server should decide whether a token is valid. The client can show a countdown for convenience, but it should treat the server response as authoritative.

Next step

Write down the states before adding another boolean. Give each state one clear user action, guard asynchronous responses with request identity, and test the transitions that happen when users retry, refresh, or return with an expired link. The result is a React flow that is easier to ship, easier to explain, and much less surprising when the network behaves badly.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.