Email verification looks like a small feature until a user clicks “send again” while a request is still running, opens an old link, or returns to the tab after the token expired. Then a simple isVerified boolean starts carrying too much meaning.
I have found a cleaner approach in React projects: model the verification journey as a small, typed state machine. It gives the UI a clear answer to “what can happen next?” and gives tests a stable contract. The result is less spinner guessing, fewer impossible screens, and a signup flow that feels intentional.
Why a boolean is too small for email verification
This is tempting:
const [isVerified, setIsVerified] = useState(false); But false could mean all of these things:
- The user has not submitted the form.
- The verification request is in flight.
- The email was sent successfully.
- The request failed temporarily.
- The link was invalid or already used.
- The link expired.
Those states need different copy, controls, and recovery actions. A user should not see a generic “not verified” message when the real next step is to check an inbox. The form dont need to know every backend detail, but it does need a trustworthy product state.
This is also where test data gets messy. A fixture containing temp gamil com or dummy e mail should be treated as an input value, not as a special UI mode. Keep data validation separate from the state model so odd addresses do not create hidden branches.
Model the flow as a typed state union
In TypeScript, a discriminated union makes each state explicit:
type VerificationState = | { kind: "idle" } | { kind: "sending"; email: string } | { kind: "sent"; email: string; sentAt: number } | { kind: "verified"; email: string } | { kind: "failed"; email: string; message: string } | { kind: "expired"; email: string }; Now the renderer has a finite set of cases. If the backend later adds a “blocked” response, the compiler can help find every UI branch that needs a decision. That is more useful than allowing a new string to flow through and silently render the wrong panel.
The reducer can stay small:
type Action = | { type: "submit"; email: string } | { type: "sent"; email: string; sentAt: number } | { type: "verified"; email: string } | { type: "expired"; email: string } | { type: "error"; email: string; message: string }; function reduceVerification( state: VerificationState, action: Action, ): VerificationState { switch (action.type) { case "submit": return { kind: "sending", email: action.email }; case "sent": return { kind: "sent", email: action.email, sentAt: action.sentAt }; case "verified": return { kind: "verified", email: action.email }; case "expired": return { kind: "expired", email: action.email }; case "error": return { kind: "failed", email: action.email, message: action.message }; } } The reducer does not call the API. It only describes how known events change the screen. That separation keeps the component easier to read, and it makes transition tests quick.
Keep the React UI honest during async work
A state union is useful only if the UI respects it. Render actions from the current state rather than sprinkling independent loading and error flags through the component:
function VerificationPanel({ state }: { state: VerificationState }) { switch (state.kind) { case "idle": return <EmailForm />; case "sending": return <p aria-live="polite">Sending a verification link…</p>; case "sent": return ( <section> <p aria-live="polite">Check {state.email} for the link.</p> <ResendButton email={state.email} /> </section> ); case "verified": return <p>Verified. You can continue.</p>; case "expired": return <ExpiredNotice email={state.email} />; case "failed": return <ErrorNotice message={state.message} />; } } Disable duplicate actions while sending, but do not disable the entire page. Keyboard focus should land on the next useful control when the state changes. An accessible status message are often more helpful than a full-screen spinner.
Also decide what happens when an old request finishes after a newer one. Attach a request ID or use an abort controller so a slow response cannot move a newer sent state back to failed. Race conditions is easier to prevent at the event boundary than to patch in the JSX.
Test transitions with isolated email fixtures
Test the state transitions first, then add a smaller number of component tests for accessible copy and controls. The important cases are not just “success” and “failure”; they are the edges between states.
it("shows the inbox state after the send event", () => { const next = reduceVerification( { kind: "sending", email: "dev@example.test" }, { type: "sent", email: "dev@example.test", sentAt: 1700000000000 }, ); expect(next).toEqual({ kind: "sent", email: "dev@example.test", sentAt: 1700000000000, }); }); Use a fresh disposable email fixture per test run when the flow requires an actual message. Do not share one inbox across parallel tests: one test can consume the link while another is still waiting. The same principle applies to password reset and OAuth scenarios; proof-carrying verification tests make the causal chain visible instead of relying on timing.
For API-backed suites, give each test a run identifier and clean up messages after the assertion. Isolated signup inboxes help keep a passing test from accidentally reading another test's email. It sounds basic, but shared inboxes create flakes that are very hard to reproduce locally.
A quick review checklist
Before shipping an email verification screen, ask:
- Can every visible state be named in one TypeScript union?
- Does each state have one clear next action?
- Can an old request overwrite a newer state?
- Are resend, expired-link, and invalid-link paths tested?
- Does the UI announce async status without stealing focus?
- Does every parallel test own its email fixture?
If the answers are clear, the feature usually becomes simpler to change. New backend responses have a place to go, and the UI is less likely to show an impossible combination of loading, success, and error messages.
Common questions
Should I use a state machine library?
Not necessarily. A discriminated union and a reducer are enough for a compact flow. Consider a library when the number of states, guarded transitions, or long-running effects makes the rules hard to see in one file.
Should verification state live in global state?
Usually no. Keep it local to the signup or account flow until another screen genuinely needs it. Global state makes stale email and token data easier to retain than intended.
Is a disposable email fixture safe in production tests?
It can be appropriate for controlled test accounts, but keep production customer data out of shared test inboxes. Define retention and cleanup rules, and make the fixture boundary obvious to the team.
Typed states are a small design choice, but they pay off quickly. The UI explains what is happening, tests can target real transitions, and the next React feature does not need to reverse-engineer a pile of booleans. That is a better verification flow for developers and users both.


