DEV Community

ryanlee
ryanlee

Posted on

React Email Checks Need One Source of Truth

If your signup form still stores email validation as a loose pile of booleans, that field is probably lying to your UI from time to time. I keep seeing the same combo in production React apps: isChecking, isAvailable, error, maybe hasBlurred, and then one slow request arrives late and the screen says something that is no longer true. It looks fine in demos, but under real typing speed it gets messy fast.

Lately I have been treating email checks as one workflow with one state shape. That change is not flashy, but it makes forms easier to reason about, easier to test, and way less twitchy for users.

Why email checks drift out of sync

The problem is not email syntax. The problem is everything wrapped around it:

  • local format validation
  • async availability checks
  • policy checks for blocked domains
  • optional handling for a free throwaway email during trials or QA flows
  • submit rules that depend on the latest result

When each piece writes to a different boolean, the component can end up in impossible states. A spinner keeps spinning after an error. A success message survives after the user changed the address. Submit becomes enabled because a request finished, not because the latest one finished. I've debugged this more than once, and its always the same flavor of bug.

This gets more important as teams add product rules. Some apps block disposable inboxes. Some allow them for sandboxes. Some flag them for review. Once that nuance exists, the field is not a yes/no check anymore.

According to the 2024 Stack Overflow Developer Survey, JavaScript and TypeScript remain deeply entrenched in production stacks. That matters because a lot of teams already have the tools to model these states better, they just have not made the state model explicit yet.

Treat the field as one workflow

My preferred fix is simple: give the field a single state object, and make every render path depend on that object. In practice, I usually start with a discriminated union in TypeScript:

type EmailCheckState =
  | { kind: "idle" }
  | { kind: "typing"; value: string }
  | { kind: "checking"; value: string }
  | { kind: "valid"; value: string }
  | { kind: "invalid"; value: string; message: string }
  | { kind: "error"; value: string; message: string };
Enter fullscreen mode Exit fullscreen mode

The important bit is not TypeScript purity. The important bit is that the UI now has one honest answer to the question, "what is happening with this field right now?"

That cleans up rendering immediately:

function EmailHint({ state }: { state: EmailCheckState }) {
  switch (state.kind) {
    case "idle":
    case "typing":
      return null;
    case "checking":
      return <p>Checking address...</p>;
    case "valid":
      return <p>You can keep going.</p>;
    case "invalid":
    case "error":
      return <p>{state.message}</p>;
  }
}
Enter fullscreen mode Exit fullscreen mode

It also makes test cases more direct. Instead of asserting five booleans after every interaction, you assert one state transition. That is a small thing, but it saves time every single week.

If you're also dealing with auth links, I liked the framing in safer magic-link audit patterns: reduce ambiguous states early, then log or display only what you can defend.

A small React hook that keeps results honest

The second half of the bug is stale requests. User types sam@old.com, request starts, user updates to sam@new.com, second request starts, first request finishes last, and boom, old data wins. Not great!

You do not need a giant library to fix this. A very small hook plus AbortController handles the core issue:

import { useRef, useState } from "react";

export function useEmailAvailability() {
  const [state, setState] = useState<EmailCheckState>({ kind: "idle" });
  const activeRequest = useRef<AbortController | null>(null);

  async function checkEmail(value: string) {
    activeRequest.current?.abort();

    const controller = new AbortController();
    activeRequest.current = controller;
    setState({ kind: "checking", value });

    try {
      const response = await fetch(`/api/email-check?email=${encodeURIComponent(value)}`, {
        signal: controller.signal
      });

      const result: { ok: boolean; message?: string } = await response.json();

      if (!response.ok) {
        throw new Error(result.message ?? "Request failed");
      }

      if (result.ok) {
        setState({ kind: "valid", value });
      } else {
        setState({
          kind: "invalid",
          value,
          message: result.message ?? "This address cannot be used."
        });
      }
    } catch (error) {
      if (controller.signal.aborted) return;

      setState({
        kind: "error",
        value,
        message: error instanceof Error ? error.message : "Unknown error"
      });
    }
  }

  return { state, checkEmail };
}
Enter fullscreen mode Exit fullscreen mode

This is one of those patterns that feels almost too basic, but basic is what you want here. The form becomes more predictable, QA gets fewer "can not repro" issues, and your product team stops seeing weird flicker in demos.

It also pairs nicely with automated test work. If your end-to-end checks still flake around inbox timing, this write-up on email test failures that only show up in CI hits a similar principle: make async boundaries explicit or they will punish you later.

When to check and when to wait

I do not love validating on every keystroke unless the product really needs it. For most signups, a better default is:

  • run syntax checks locally while typing
  • run async checks on blur or after a short debounce
  • keep submit rules tied to the latest known state

That last part matters the most. "Request finished" is not the same as "field is safe." Your submit gate should only trust the newest response for the newest value. Sounds obvious, but this is where many forms go a bit off the rails.

I also like separating "invalid" from "allowed with caveat." For example, some teams allow a dummy e mail or a tempail mail in internal QA, partner demos, or sandbox signups. That is not the same product rule as "address is malformed" or "domain is blocked." Make the distinction real in the API and the UI gets much clearer.

One more practical note: if the server owns the policy, keep the frontend descriptive, not authoritative. The client can show helpful guidance, but the backend should be the final judge so web, admin, and mobile all behave the same. Saves a lot of pain later tbh.

Quick Q&A

Should I block submit while a check is in flight?

Sometimes. If the email policy is critical to account creation, yes. If the check is advisory, maybe not. Tie the rule to product risk, not habit.

Do I need a state machine library?

Usually no. A typed union plus disciplined transitions is enough for many forms. Add a library only when the wider flow really needs it.

Is debounce better than blur?

Not always. Blur is simpler and often good enough. Debounce helps when near-live feedback matters, but too much of it can make the form feel a little odd and slow.

Email validation bugs are rarely dramatic, but they quietly damage conversion and trust. One state shape, one latest request, and one honest UI will get you farther than another pile of booleans ever will.

Top comments (0)