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

Typed Verification Timelines for React Forms

Email verification looks like a small form feature: collect an address, send a code, and show a success message. In a real product, it is a timeline with several actors: the browser, the API, the mail provider, a user wh

Email verification looks like a small form feature: collect an address, send a code, and show a success message. In a real product, it is a timeline with several actors: the browser, the API, the mail provider, a user who may open two tabs, and a timer that eventually expires.

When those actors disagree, a React component often falls back to a handful of booleans:

const [loading, setLoading] = useState(false)
const [verified, setVerified] = useState(false)
const [error, setError] = useState<string | null>(null)

This works for the happy path. It gets confusing when a resend request finishes after an old verification request, or when a user returns to a tab after the code has expired. A typed timeline makes the UI easier to reason about and gives tests something more useful than a screenshot to assert.

Why a boolean is not enough

loading, verified, and error can all be true at the same time. That means the component can accidentally render β€œVerified” while a newer request is still pending. The UI should not guess what the API mean; it should render a state that already explains the server decision.

A useful model starts with mutually exclusive states:

type VerificationState =
  | { kind: "idle" }
  | { kind: "sending"; requestId: string }
  | { kind: "awaiting_code"; expiresAt: number; attempt: number }
  | { kind: "checking_code"; attempt: number }
  | { kind: "verified"; verifiedAt: number }
  | { kind: "expired"; expiredAt: number }
  | { kind: "failed"; message: string; retryable: boolean }

The names are intentionally plain. They describe what the user can do next, not which component happened to run last. The state names are boring, but they makes the product behavior much easier to discuss with support and QA.

Model the verification timeline

The transition rules belong in one place. A reducer is a good fit because every event can be logged and replayed:

type VerificationEvent =
  | { type: "SEND_STARTED"; requestId: string }
  | { type: "CODE_SENT"; expiresAt: number }
  | { type: "CODE_CHECK_STARTED" }
  | { type: "CODE_ACCEPTED"; verifiedAt: number }
  | { type: "CODE_REJECTED"; message: string }
  | { type: "CLOCK_EXPIRED"; expiredAt: number }
  | { type: "RESET" }

function reduceVerification(
  state: VerificationState,
  event: VerificationEvent,
): VerificationState {
  switch (event.type) {
    case "SEND_STARTED":
      return { kind: "sending", requestId: event.requestId }
    case "CODE_SENT":
      return { kind: "awaiting_code", expiresAt: event.expiresAt, attempt: 0 }
    case "CODE_CHECK_STARTED":
      return state.kind === "awaiting_code"
        ? { kind: "checking_code", attempt: state.attempt + 1 }
        : state
    case "CODE_ACCEPTED":
      return { kind: "verified", verifiedAt: event.verifiedAt }
    case "CODE_REJECTED":
      return { kind: "failed", message: event.message, retryable: true }
    case "CLOCK_EXPIRED":
      return state.kind === "verified"
        ? state
        : { kind: "expired", expiredAt: event.expiredAt }
    case "RESET":
      return { kind: "idle" }
  }
}

The reducer does not need to know whether the message arrived through a normal inbox, a run-scoped email fixture, or a service such as tempmailso. That boundary is useful: the provider produces events, while the product owns the meaning of those events. For a broader fixture strategy, see run-scoped email fixtures.

The server should still be authoritative. A client-side timer can show that a code is probably expired, but only the API can confirm whether a code is valid. When the timer expire, dispatch an event that changes the hint in the UI; do not pretend it has changed the server record.

Keep the React UI honest

Render from state.kind, then make each action explicit:

function VerificationPanel({ state }: { state: VerificationState }) {
  switch (state.kind) {
    case "idle":
      return <button type="submit">Send code</button>
    case "sending":
      return <p role="status">Sending a new code…</p>
    case "awaiting_code":
      return <CodeEntry expiresAt={state.expiresAt} />
    case "checking_code":
      return <p role="status">Checking the code…</p>
    case "verified":
      return <p role="status">Email verified.</p>
    case "expired":
      return <ResendButton />
    case "failed":
      return <RetryMessage message={state.message} retryable={state.retryable} />
  }
}

This prevents stale success messages from competing with a resend action. It also makes accessibility behavior more deliberate: transient updates can use role="status", while the code input remains available when a retry is valid.

Keep the timer separate from the state reducer. A hook can schedule one timeout for the current expiresAt, clean it up when the value changes, and dispatch CLOCK_EXPIRED. In practise, that separation makes fake-clock tests much less fragile. Its a small detail, but it avoids a timer callback reading an old closure.

If an OAuth or login flow also carries a state value, treat that value as another replay boundary rather than mixing it into the form booleans. This related explanation of a replay boundary for authentication state is a useful companion when the verification page is part of a larger sign-in flow.

Test transitions instead of screenshots

The highest-value tests assert event sequences. For example:

it("does not expire an already verified address", () => {
  const verified = reduceVerification(
    { kind: "verified", verifiedAt: 100 },
    { type: "CLOCK_EXPIRED", expiredAt: 200 },
  )

  expect(verified).toEqual({ kind: "verified", verifiedAt: 100 })
})

Add cases for a late rejection after a resend, an expired code followed by a successful new send, and two tabs receiving different results. These tests expose the contract between API responses and UI states. They also fail with a compact diff when behavior changes, instead of requiring someone to inspect a pixel-level snapshot.

Some teams label their disposable fixture a β€œtemp mailid” in test data. The label is less important than its lifecycle: assign it to one run, record the message identifier, and clean it up when the test ends.

A small rollout checklist

  1. Replace related booleans with a discriminated union.
  2. List every API response as an event before changing the component.
  3. Keep server truth separate from client timeout hints.
  4. Test late, duplicated, and out-of-order responses.
  5. Make retry and resend actions visible from the current state.
  6. Log a request or correlation ID for failures, but never log verification codes.

This approach scales beyond email. The same timeline works for phone challenges, payment confirmation, and background imports. Once the UI has one honest state at a time, React stops being the place where async ambiguity gets hidden.

Common questions

Should the client mark a code as expired?

It can show an expired hint when its local clock passes the deadline, but the API must decide whether a submitted code is accepted. Client time is feedback, not authority.

Do I need a state machine library?

No. A TypeScript union and a small reducer are enough for many forms. Consider a library when the workflow has nested states, parallel actors, or a team that benefits from visualized transitions.

What if the email provider is slow?

Keep sending and awaiting_code distinct, show a clear status message, and offer a safe retry policy. This avoid making a slow inbox look like a broken form while still giving the user a next action.

πŸ“° 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.