Email verification looks like a small React feature until the product has to handle resend buttons, expired codes, two browser tabs, slow inboxes, and a user who clicks submit twice. At that point, a few booleans such as isLoading and isVerified stop explaining what the screen can actually do.
The fix is a small contract between the UI, the API, and the test suite. Model the meaningful states in TypeScript, make transitions explicit, and let the component render that model. The result is less clever code and fewer “why is this button stuck?” bugs.
The hidden contract in an email verification form
A verification form usually has more states than its JSX suggests:
- The user has not requested a code.
- A code was requested and delivery is pending.
- The code can be entered.
- The code is being checked.
- The code is accepted, expired, or rejected.
- A new code can be requested after a cooldown.
If those states are represented by independent flags, impossible combinations become easy to create. isLoading: false and isVerified: true may be fine, but what does isExpired: true mean when the old success message is still visible? The UI dont have a single answer because the contract is spread across unrelated variables.
Start with a discriminated union instead:
type VerificationState =
| { kind: "idle" }
| { kind: "sending" }
| { kind: "ready"; email: string }
| { kind: "checking"; email: string }
| { kind: "verified"; email: string }
| { kind: "failed"; email: string; message: string }
| { kind: "expired"; email: string };
Now each render branch has a name. It also becomes harder to forget data needed by a state, such as the email address associated with the current code.
Model the flow as explicit TypeScript states
Keep events separate from the state they change:
type VerificationEvent =
| { type: "request-code"; email: string }
| { type: "code-sent" }
| { type: "submit-code"; code: string }
| { type: "verified" }
| { type: "expired" }
| { type: "failed"; message: string };
A reducer or a small state-machine helper can reject invalid transitions. For example, a submit-code event should do nothing while the form is idle, and a second submit should not start a second verification request while the state is already checking.
This is useful at the API boundary too. I like returning a typed result instead of making the component inspect arbitrary error strings:
type VerifyResult =
| { ok: true }
| { ok: false; reason: "invalid" | "expired" | "rate_limited" };
The server may still return more diagnostic detail for logs, but the browser gets only the decisions it needs. A reponse such as rate_limited can show a retry hint, while expired can offer a fresh code.
Keep the React component boring
The component should mostly connect events to the model and render the current state. Avoid hiding verification behavior in effects that depend on half a dozen flags. An explicit handler is easier to trace:
function VerifyEmail({ email }: { email: string }) {
const [state, dispatch] = useReducer(reducer, { kind: "idle" });
async function submitCode(code: string) {
if (state.kind !== "ready") return;
dispatch({ type: "submit-code", code });
const result = await verifyCode(email, code);
dispatch(
result.ok
? { type: "verified" }
: result.reason === "expired"
? { type: "expired" }
: { type: "failed", message: "That code was not accepted." },
);
}
if (state.kind === "verified") return <p>Email verified.</p>;
if (state.kind === "expired") return <ResendCode email={email} />;
return <CodeForm disabled={state.kind === "checking"} onSubmit={submitCode} />;
}
There is a tradeoff: this introduces a few more lines than three booleans. But the states are visible, the loading guard is local, and a future teammate can change the flow without guessing which flag wins.
Test each transition with a stable mailbox boundary
Test transitions, not implementation details. A useful suite covers:
- A successful request moves the form from
idletoready. - A correct code reaches
verifiedexactly once. - An expired code offers resend without retaining a success message.
- A double click creates one verification request.
- A late response cannot overwrite a newer request.
For end-to-end tests, keep the mailbox identity separate from the user identity. The test should know which address belongs to the run, which code was received, and when that code became invalid. An artifact contract for email CI is a useful pattern when a test runner needs to pass those facts between jobs.
The test is repeatable becuase its mailbox is created for the scenario, not borrowed from a shared inbox. If your environment needs a disposable fixture, a throwaway email generator can be one input to that boundary; keep the provider behind a test adapter so the React tests dont depend on a particular service.
Also record the run ID and transition history in test artifacts. A failed test that says “verification timed out” is less useful than one that shows ready -> checking -> expired and the last observed message. That little ledger makes CI triage faster.
Where a throwaway inbox fits
A temporary inbox is a test resource, not an application identity. Give it a short lifetime, never use it for real user data, and clean it up after the scenario. Avoid putting mailbox contents into screenshots or logs when a message ID will do.
The same boundary works for local development. A developer can run the UI against a fake adapter, then swap in a real test mailbox for one integration check. Topic scores for automation decisions are interesting for content systems, but product code benefits from a simpler rule: choose the smallest dependency that proves the transition.
If someone searches for “temp mailid” or “tamp mail com” while documenting a test, keep those phrases out of code identifiers and treat them as search noise. The important part is still ownership, expiry, and a clear contract.
A practical checklist
Before shipping the form, check that:
- every visible state has one owner in the model;
- retry and double-submit behavior is defined;
- expired and invalid codes render different next actions;
- the API result is narrowed before it reaches JSX;
- tests assert transitions and request counts;
- test mailboxes are isolated, time-bounded, and redacted in logs.
The goal is not to build a huge framework for one form. It is to make the behavior explicit enough that React, TypeScript, the API, and CI all agree. When the contract is small and typed, email verification becomes a feature you can extend instead of a corner of the app everyone is afraid to touch.
Top comments (0)