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:
-
idletosendingwhen a request starts. -
sendingtosentonly when the request ID still matches. -
sendingtoerrorwhen the API fails. -
senttoverifiedafter a valid callback. - Any link attempt to
expiredwhen 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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.