If your React signup form still treats email validation like a boolean, you're probably carrying more UI bugs than you think. I keep seeing the same pattern in product code: isValid, isLoading, maybe error, and then a few race conditions sneak in when users type fast, tab away, or retry after a slow response. It works until it very much does not.
Lately I have been moving these flows to a typed request model in TypeScript. The win is fewer impossible UI states and less guesswork when async checks come back out of order.
Why typed email checks reduce UI bugs
An email field often does more than syntax validation. It may check whether the address already exists, whether the domain is blocked, or whether a temp mailbox should be accepted for a trial flow. That means one field can move through multiple states:
- untouched
- editing
- checking
- available
- unavailable
- failed
When those states are compressed into a couple booleans, teams start shipping contradictory UI. A spinner can show while an old error message is still visible. A success state can flash for the wrong value. A submit button can unlock because one request finished, not because the latest request finished.
According to the 2024 Stack Overflow Developer Survey, TypeScript remains one of the most used technologies among professional developers, which makes sense here: forms are exactly where stronger state modeling pays off.
Model the request state first
I like starting with the state machine before I write the fetch call. Nothing fancy, just a discriminated union that mirrors what the user can actually see.
type EmailCheckState =
| { kind: "idle" }
| { kind: "checking"; value: string }
| { kind: "available"; value: string }
| { kind: "unavailable"; value: string; reason: string }
| { kind: "error"; value: string; message: string };
That gives the UI one honest source of truth. The rendering code gets simpler too:
function EmailStatus({ state }: { state: EmailCheckState }) {
switch (state.kind) {
case "idle":
return null;
case "checking":
return <p>Checking email...</p>;
case "available":
return <p>Email looks good.</p>;
case "unavailable":
return <p>{state.reason}</p>;
case "error":
return <p>{state.message}</p>;
}
}
This is the same reason I still like the email state machine pattern for signup work: you decide the allowed transitions upfront, then the component gets less weird over time.
For product teams, this also creates a better handoff with backend folks. You define the exact outcomes the UI expects, which means fewer surprise branches and less "works on my machine" energy.
Use cancellation so stale results never win
The classic bug in async email checks is simple: user types a@x.com, request starts, user changes it to alex@x.com, second request starts, first request finishes last and overwrites the newer result. Oof.
You can fix this with AbortController and one rule: only the latest value gets to update state.
import { useRef, useState } from "react";
export function useEmailCheck() {
const [state, setState] = useState<EmailCheckState>({ kind: "idle" });
const activeController = useRef<AbortController | null>(null);
async function checkEmail(value: string) {
activeController.current?.abort();
const controller = new AbortController();
activeController.current = controller;
setState({ kind: "checking", value });
try {
const response = await fetch(`/api/email-check?email=${encodeURIComponent(value)}`, {
signal: controller.signal
});
if (!response.ok) {
throw new Error("Request failed");
}
const result: { available: boolean; reason?: string } = await response.json();
if (result.available) {
setState({ kind: "available", value });
} else {
setState({
kind: "unavailable",
value,
reason: result.reason ?? "This email 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 };
}
This pattern is boring in the best way. Boring code is easier to trust. It also helps when your validation service has different latency by region, or when a temp mailbox lookup takes longer than your normal domain rules. If your product intentionally allows a temp mailbox for onboarding experiments, keep that decision explicit in the API response.
I have also found that cancellation reduces noisy monitoring because stale requests stop surfacing as fake failures. Similar idea, different layer, but it reminds me of these low-noise alert checks: reduce ambiguity first, then observe what is actually happening.
Keep the UX fast without lying to users
There is a temptation to validate on every keystroke because it feels "live." In many forms, that is not the best tradeoff. You get more network traffic, more state churn, and more flicker for users who are still editing.
My default setup now is:
- do syntax validation locally while typing
- run server validation on blur or after a short debounce
- keep submit disabled only when the latest known state makes submit unsafe
That last point matters. Some teams lock submit any time a check is in flight. Sometimes that is correct. Sometimes it just makes the form feel sticky. Product context matters here, and this is where typed states help a lot.
For disposable-email related experiments, I also prefer separating "allowed but flagged" from "blocked." A temp mailbox might be okay for a sandbox, a QA path, or a short-lived trial. It does not need to be treated the same as an invalid address. Even the plain-text typo term tem email has shown up in internal search logs for some teams, which is a nice reminder that users and stakeholders rarely use our clean taxonomy.
A small checklist before shipping
If you want this flow to be reliable, check these before rollout:
- stale requests are cancelled
- UI state is represented by a discriminated union, not scattered booleans
- error messages match the latest typed value
- submit logic depends on the latest valid state, not just "request finished"
- analytics distinguish syntax errors, availability failures, and transport failures
Quick Q&A
Should the frontend decide whether a disposable address is allowed?
No. The frontend can reflect policy, but the backend should own it so web, mobile, and admin tools all stay consistent.
Is debounce always required?
Not always. Blur-triggered checks are often enough. Debounce helps when the product really needs near-live feedback, but too much of it can make the form feel kinda mushy.
Do I need a full state machine library?
Usually no. A small TypeScript union plus disciplined transitions gets you most of the value without extra dependency weight.
Typed email checks are one of those engineering choices that quietly improve everything around them: fewer edge-case bugs, cleaner analytics, and a form that feels less fragile.
Top comments (0)