I like fast signup forms, but I do not trust the ones that talk too early. A lot of React teams wire an async email check straight to onChange, then wonder why the UI flashes between valid, invalid, and loading while the user is still typing. The feature looks modern in a demo, yet it feels oddly brittle in production.
When the input can trigger server checks for tempmailso, temp mail, domain rules, or account availability, the harder problem is not the fetch itself. It is state ownership. If an old response can still update the screen, the form starts lying a little bit. Users feel that before engineers do.
I have found that the cleanest fix is to make email validation abortable and stage-based. React and TypeScript are both good at this, but only if you model the states on purpose instead of piling booleans everywhere.
Why async email checks feel broken in React
The most common bug is stale success. A user types alex@gm, your app sends a request, then the user finishes alex@gmail.com. If the first request resolves last, the UI can show feedback for the wrong value. That is how you end up blocking a real address, or approving a fake e mail com fixture that should never have reached the server.
I also see teams mix three different concerns into one status label:
- syntax validation
- server-backed policy checks
- product hints such as "work email preferred"
Those are not the same thing, and users can tell when the messages are mashed together. If you want a smoother UX, keep each check in its lane. This is similar to keeping OAuth email checks isolated: boundaries make the result easier to trust.
The state model that keeps feedback honest
The state machine does not need to be fancy. Mine is usualy:
-
idlewhen the field is empty -
typingwhile the value is changing -
checkingwhen one request is in flight for the current value -
validwhen the latest response matches the latest typed value -
invalidwhen the latest response matches the latest typed value and should block submit -
errorwhen the request failed and the form should degrade gracefully
The big rule is this: only the latest request can update the latest visible state.
That means every check should carry two things:
- the exact email value that triggered it
- an
AbortControllerso older work can be canceled
If your backend also flags throwaway providers or a temp mailid pattern, return a structured result instead of one generic boolean. Product teams almost always want to phrase the feedback differently later, and that change gets realy annoying if the API only returns ok: false.
A small React and TypeScript example
Here is the shape I keep reaching for:
type EmailCheck =
| { kind: "idle" }
| { kind: "typing" }
| { kind: "checking"; value: string }
| { kind: "valid"; value: string }
| { kind: "invalid"; value: string; reason: string }
| { kind: "error"; message: string };
function SignupEmailField() {
const [email, setEmail] = useState("");
const [check, setCheck] = useState<EmailCheck>({ kind: "idle" });
useEffect(() => {
if (!email) {
setCheck({ kind: "idle" });
return;
}
setCheck({ kind: "typing" });
const controller = new AbortController();
const timer = window.setTimeout(() => {
setCheck({ kind: "checking", value: email });
fetch(`/api/email-policy?email=${encodeURIComponent(email)}`, {
signal: controller.signal
})
.then((r) => r.json())
.then((result) => {
if (result.allowed) setCheck({ kind: "valid", value: email });
else setCheck({ kind: "invalid", value: email, reason: result.reason });
})
.catch((error) => {
if (error.name !== "AbortError") {
setCheck({ kind: "error", message: "Could not verify email right now." });
}
});
}, 250);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [email]);
}
This is not the final polished component, but it shows the idea: debounce a bit, tie the request to the current value, and never pretend an old answer belongs to a new input. If you already built signup checks for temporary inboxes, this is the next step that makes the UI feel less twitchy.
One caveat: if your framework setup does not let the cleanup abort the exact in-flight request cleanly, move the fetch into a small helper that owns the controller lifecycle. That is slightly more code, but much easier to reason about later. It looks a touch more verbose, but the debugging story is way better.
Where teams usually over-validate
I do not think every email field needs a live remote check. In many flows, local syntax validation plus a server check on submit is enough. Real-time checks help when:
- the product blocks disposable domains before account creation
- you want early feedback for enterprise domain rules
- signup abuse is costly enough that earlier friction is worth it
They hurt when:
- every keystroke can flash a warning
- the API is slow or rate-limited
- the UI suggests certainty where there is only a network guess
That tradeoff matters more than most teams admit. A flashy validator can easily become a confidence bug. And once people stop trusting the field, they start retrying random addresses, which creates even more noisy signals in analytics. It sounds small, but this kind of friction can snowbal into support noise pretty fast.
Q&A
Should I block submit while the check is running?
Only if the policy is critical. If the check is mostly advisory, let submit continue and re-check on the server. Blocking too aggressively makes the form feel sticky, and users will notice it imediately.
What should the API return?
A typed result is best: allowed, reason, and maybe category. That gives the frontend room to explain whether the issue is syntax, risk policy, or unsupported domain. That extra context is suprisingly useful for product copy too.
Do I need live checks for every signup form?
No. Sometimes the honest answer is a simpler form with fewer moving parts. I think that is better than pretending a noisy async validator is "smart" when it is mostly guessing. Keep it usefull, not just clever.
Top comments (0)