DEV Community

ryanlee
ryanlee

Posted on

Ship Email Checks Without UI Drift

Email signup bugs rarely come from the regex anymore. They usually come from drift between the React field state, the Node.js validation endpoint, and the product rule nobody wrote down. One request finishes late, one message stays on screen too long, and suddenly the form feels broken even when the API is fine.

I like treating email validation as a small product contract instead of a one-off helper. That sounds a bit grand for one field, but it keeps teams from shipping contradictory states. The payoff is realy practical: fewer support tickets, cleaner analytics, and less "why did this button enable?" debugging at 6 PM.

According to the 2024 Stack Overflow Developer Survey, JavaScript and TypeScript remain among the most-used technologies. That matters because async form behavior is where loose contracts tend to hurt the most.

Why UI drift happens in email validation

Most teams start with a few booleans:

  • isChecking
  • isAvailable
  • error

That works for the happy path, then breaks once users type quickly or retry after a slow network hop. The UI may show "email available" for an older value, while the latest request is still running. I keep seeing this in product work because the field is doing three jobs at once:

  • syntax validation
  • availability checks
  • policy checks for domains, aliases, or temporary inboxes

When those concerns are merged into one fuzzy result, the form gets weird fast. A good example from the account layer is this post on state-bound recovery tokens: once a security-sensitive step is disconnected from its active state, bugs get subtle and expensive.

Give the frontend and backend the same contract

The cleanest fix is to agree on a typed response model first, then let React render from that model directly.

type EmailCheckResult =
  | { kind: "available"; email: string }
  | { kind: "unavailable"; email: string; reason: "taken" | "blocked_domain" | "temporary_disallowed" }
  | { kind: "retry"; email: string; message: string };
Enter fullscreen mode Exit fullscreen mode

On the Node.js side, that means your endpoint should describe policy instead of leaking random implementation details:

app.get("/api/email-check", async (req, res) => {
  const email = String(req.query.email || "").trim().toLowerCase();

  if (!email.includes("@")) {
    return res.status(400).json({ kind: "retry", email, message: "Invalid email format" });
  }

  const exists = await userRepo.exists(email);
  if (exists) {
    return res.json({ kind: "unavailable", email, reason: "taken" });
  }

  return res.json({ kind: "available", email });
});
Enter fullscreen mode Exit fullscreen mode

This keeps product copy, analytics, and submit rules aligned. It also makes instrumentation much easier, which is why I still like borrowing ideas from dockerized email delivery checks: explicit states beat vague pass/fail every time.

Abort stale checks and label every request

The second fix is refusing to let old requests win. If the user changes ana@example.com to anna@example.com, the first response should be ignored even if it returns later.

const requestIdRef = useRef(0);

async function checkEmail(email: string) {
  const requestId = ++requestIdRef.current;
  setState({ kind: "checking", email });

  const res = await fetch(`/api/email-check?email=${encodeURIComponent(email)}`);
  const data = await res.json();

  if (requestId !== requestIdRef.current) return;
  setState(data);
}
Enter fullscreen mode Exit fullscreen mode

If you can use AbortController, even better. Use both when the code path is busy. It sounds slightly overbuilt, but it saves you from ghost states that are annoying to repro later. This is also where I log the raw input separately from the normalized input, because search terms from support or growth teams can be messy. You will see things like fake e mail com or temp gamil com in dashboards, and it is useful to know whether the user typed that or your normalizer produced it.

Decide how disposable addresses should behave

This part is usually where teams get hand-wavey. Someone says "block temp mail," someone else says "QA needs it," and the code ends up half-open.

Be explicit:

  • blocked for production onboarding
  • allowed for sandbox or demos
  • flagged for review in abuse-heavy flows

If your product does allow a throwaway email address in some cases, document why and return that policy from the API. I would rather see one plain response like "temporary inbox allowed for trial tier" than a hidden rule living across three files and one Slack memory. Tools such as tempmailso are not the problem by themselves; unclear product policy is.

One thing that helps a lot is separating transport checks from policy checks. "The mailbox format is valid" is not the same as "this address fits our signup rules." Teams mix those together all the time, and then the UX gets kinda brittle.

A quick Q&A before you ship

Should React decide whether submit is enabled?

Only partly. React should reflect the latest safe state, but the backend still owns the final rule. Otherwise mobile, web, and internal tooling slowly diverge.

Should every keystroke hit the server?

Usually no. Local syntax checks while typing, then debounce or blur-based availability checks, is a much saner default. It feels fast without spamming the API.

Is this overkill for one field?

Not once the field affects onboarding conversion, fraud prevention, or support load. Email is a tiny surface with a surprising amount of product weight, so a small contract goes a long way.

Ship the contract first, then the spinner text, and the whole flow gets easier to reason about. Not perfect, maybe, but much less fragile and a lot more honest.

Top comments (0)