React 가입을 위해 입력한 이메일 상태

작성자

카테고리:

← 피드로
DEV Community · ryanlee · 2026-09-25 개발(SW)

Email verification is usually introduced to a React component as one boolean: isVerified. That works for a demo, but a production signup flow has more states than verified or not verified.

The browser may be waiting for a message, polling for a code, showing an expired link, handling a retry, or receiving a response from an older request. If all of those cases are squeezed into a few booleans, the UI starts making promises it cannot keep.

This is where a small TypeScript state model helps. The goal is not to build a formal state-machine library. It is to make every meaningful screen state explicit, so the component can be easier to ship and harder to confuse.

Why a boolean is not enough

A signup screen do not have only two meaningful states. Consider this short sequence:

  1. The user submits an email address.
  2. The app sends a verification request and shows a pending message.
  3. The user edits the address while the first request is still running.
  4. The first response arrives after the second submission.
  5. The user opens a verification link that has expired.

With isVerified, isLoading, and hasError, combinations such as “loading and expired” or “verified and showing an old error” are easy to create accidentally. The UI should know what it can safely show, and what it should not promise.

A useful state model has one active state at a time. It can also carry the data needed to render that state, such as the submitted address or a request identifier.

Model the flow with a discriminated union

TypeScript discriminated unions are a good fit because the status field narrows the rest of the object automatically:

type EmailState =
  | { status: "idle" }
  | { status: "sending"; requestId: number; address: string }
  | { status: "waiting"; address: string }
  | { status: "verified"; address: string }
  | { status: "expired"; address: string }
  | { status: "error"; message: string };

Enter fullscreen mode Exit fullscreen mode

Now a component can render from a known set of cases instead of guessing which flags win. A sending state can disable the submit button, while waiting can explain that the user should check their inbox. An expired state can offer a clear resend action without pretending the old link is still valid.

The model also makes copy decisions visible. For example, a user searching for a “free throwaway email” may be testing a signup flow, but the product should still communicate whether a message was sent, whether it is pending, or whether the address needs another attempt. Those are product states, not just network details.

For a wider discussion of email states in a signup flow, the important idea is the same: an email click proves a narrow thing at a particular time. The client should not turn it into a permanent, vague success flag.

Keep async responses from fighting the UI

The most annoying bug in these forms is a stale response. A user submits [email protected], changes the field, then submits [email protected]. If the first request finishes last, it can overwrite the state for the second address.

One lightweight fix is to issue a local request ID and ignore responses that no longer belong to the current request:

const requestNumber = useRef(0);
const [emailState, setEmailState] = useState<EmailState>({ status: "idle" });

async function sendVerification(address: string) {
  const requestId = ++requestNumber.current;
  setEmailState({ status: "sending", requestId, address });

  try {
    const result = await requestVerification(address);
    if (requestId !== requestNumber.current) return;

    setEmailState(
      result.expired
        ? { status: "expired", address }
        : { status: "waiting", address },
    );
  } catch {
    if (requestId === requestNumber.current) {
      setEmailState({ status: "error", message: "Could not send the message." });
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

When a response arrive late, it becomes harmless instead of becoming a visible lie. AbortController is still useful for saving work, but cancellation alone should not be the correctness rule: a server may finish just as the browser aborts.

The reducer keep this same rule if the flow grows to include code entry, resend cooldowns, or a second verification method. Keep the request identity near the transition that starts the request, and make the stale-response check part of the transition back to the UI.

Make the component accessible

Typed state is only useful if it produces clear feedback. Render status text in a region with aria-live="polite", and do not rely on color alone to distinguish waiting, success, and failure. Move focus to an error summary when the user needs to act, but avoid stealing focus for routine progress messages.

The boundary are especially important for screen-reader users: the input should have a stable label, the resend button should explain what it does, and a disabled button should not be the only signal that a request is pending. Keep the address visible in the message so users can catch a typo before checking the wrong inbox.

Small search typos such as “tem email” and “tamp mail com” can appear in support tickets or test notes. They are not application states, but they are a reminder that users do not always describe an email problem with the same words your team uses. Error copy should be human and specific.

Testing the state transitions

Test the state model rather than only clicking through the happy path. A good test suite also check that:

  • submitting an address moves idle to sending;
  • a successful response moves sending to waiting;
  • an expired response renders a resend action;
  • a rejected request shows a useful error;
  • a late response from an older request does not replace newer state;
  • the status region announces the important change;
  • a second click cannot create confusing duplicate feedback.

For browser tests, control the promise resolution order. Resolve the second request first, then resolve the first request and assert that the UI still shows the second address. This catches a bug that ordinary sequential mocks will miss.

If the flow runs in CI, include the request ID or test-run ID in logs and artifacts. That makes parallel failures reviewable; this pattern of run IDs for traceable email checks is useful beyond signup forms.

A practical checklist

Before shipping a React email verification flow, ask:

  • Is every meaningful screen state represented by one explicit status?
  • Can an older response overwrite a newer submission?
  • Does the UI distinguish pending, expired, failed, and verified outcomes?
  • Are status changes available to assistive technology?
  • Can users see which address the app is waiting on?
  • Do tests resolve concurrent requests in an awkward order?
  • Are logs specific enough to connect a failure to one run?

This make the component a little more deliberate, but it also makes product changes faster. When a new outcome appears, TypeScript points to the places that need a decision. That is a much better development loop than adding one more boolean and hoping the combinations stay friendly.

원문에서 계속 ↗