Email verification is often implemented as a success screen with one sentence: “Check your inbox.” That works until the user refreshes the page, opens an old link, clicks resend twice, or checks a disposable email address that receives messages a little slower than expected.
The hard part is not rendering the message. It is representing time and uncertainty in a way the user can understand. A React component needs to know whether a verification request is being created, waiting for a message, expired, completed, or ready for recovery. When those states are implicit, the UI feels confuse and support tickets follow.
Email verification is a time-based UI
An email token has a lifecycle. It is created, delivered, opened, accepted, or rejected after its expiry. The browser also has a lifecycle: a tab can sleep, a user can return from another device, and a refresh can remove all local component state.
Treating the flow as only loading and success hides useful decisions. For example:
- A request can be accepted by the API while the message is still in transit.
- An old link can be valid-looking but no longer usable.
- A resend can create a newer token while the user is still looking at the old message.
- A refresh should restore enough context to explain what the user can do next.
The product-minded fix is to model the flow as a small state machine. It makes the UI more predictable, and it gives tests concrete transitions to assert.
Model the states before writing components
Start with states that describe user-visible facts, not implementation details. A useful set is:
-
idle: no verification request has started. -
sending: the app is creating or resending a verification request. -
waiting: the request was accepted and the user is checking their inbox. -
expired: the current token is no longer usable. -
verified: the account has been confirmed. -
error: the app can offer a recovery action.
Each state should have an intentional next action. waiting can offer “Check again” and “Send a new link.” expired should make resend prominent. verified should move the user forward automatically or show a clear continue button. There is no need to make people guess wether a second click is safe.
This contract also keeps the React component from mixing network errors, token errors, and display state into one boolean. That boolean always become a problem once the flow grows.
A TypeScript state model
TypeScript discriminated unions are a good fit because each state can carry only the data it needs:
type VerificationState =
| { kind: "idle" }
| { kind: "sending"; requestId: string }
| { kind: "waiting"; requestId: string; expiresAt: string; emailHint: string }
| { kind: "expired"; emailHint: string }
| { kind: "verified"; accountId: string }
| { kind: "error"; message: string; canRetry: boolean };
The requestId is useful for correlating the API response, inbox event, and browser test. expiresAt should come from the server rather than being guessed by the client. The client can display a countdown, but the server remains the authority when the link is used.
For a flow that uses an inbox in development or testing, keep the mailbox boundary explicit. A dedicated disposable email address per run is easier to reason about than a shared mailbox with old messages. Artifact retention for email tests is a useful related pattern when deciding what evidence a failing run should keep.
Handle refreshes and resend cooldowns
Do not store the entire verification state only in component memory. Persist a small, non-sensitive snapshot such as requestId, emailHint, and expiresAt in session storage. Never store the token itself. On mount, the app can ask the API for the current status and reconcile the snapshot with server truth.
The resend button deserves its own timer. Disable it briefly after a successful request, show when it will be available, and make the reason visible to screen readers. If the request fails, restore the action with a useful message. A button that just stays disabled feels broken, it doesnt matter that the network call had a good reason.
The server should also make resend behavior idempotent enough for accidental double clicks. Return the active request status or a new expiry time, instead of forcing the frontend to guess which message is current. If the product uses a temporary inbox for a manual check, OAuth callback logs without leaking offers a helpful reminder to keep debug evidence useful without exposing sensitive values.
One small copy detail matters: tell users that the newest email is the one to use. This prevents an old link from producing a mysterious error when a person requested three messages in a row.
Test the user journey, not just the click
Component tests can cover rendering, but the important bugs are transitions. Write tests for:
- request accepted, then waiting for delivery;
- refresh while waiting, then status recovery;
- an expired token followed by a successful resend;
- two resend clicks where only one request is created;
- a verification link for an older request;
- a slow or empty inbox response;
- a network error with a retry action.
The test does not need to preserve a full message. It can use a small fixture containing a request ID, subject, and verification URL path. For a real mailbox check, a service such as tempmailso can help isolate a disposable inbox from personal mail, but the application should still treat the server-side token status as authoritative.
Keep malformed fixture searches visible too. A typo like tempail can appear in an old test or support note, and finding it is easier when test data has a clear owner and retention rule.
Q&A: expiry, inboxes, and recovery
Should the countdown decide whether a token is valid?
No. The countdown is a hint for the user. The API must validate expiry, because a sleeping tab or a clock difference can make the browser view stale.
Should a refresh lose the “check your inbox” screen?
Usually no. Persist a safe request snapshot and fetch current status after mount. If the request cannot be found, show a recovery path instead of silently returning to the signup form.
Is a disposable email address enough for testing?
It helps isolate test data, but it is not a complete test strategy. You still need deterministic request IDs, bounded polling, cleanup, and a decision about what artifacts are retained.
Final takeaway
Email verification becomes much easier to ship when its expiry model is visible in the code. Use explicit React states, let TypeScript enforce the data each state needs, persist only safe context, and keep the server authoritative for token validity.
The result is a flow that explains what is happening even when delivery is slow or a user returns later. It is cleaner to test, less confusing to support, and a little more easier to extend when the product adds passkeys, team invites, or email changes.
Top comments (0)