Email validation feels like a small feature until a signup form starts making network calls while someone is typing. Then the UI gets slow, old responses overwrite new ones, and a perfectly valid address can end up showing an error. The fix is less about a clever regex and more about putting a clean async boundary around the work.
In React projects, I keep the input responsive first. Local format checks happen immediately, while anything involving a server or inbox happens later, with cancellation and an explicit state. This also makes test accounts and a generate disposable email workflow easier to reason about.
The async boundary problem
There are three different questions hiding behind one email field:
- What has the user typed so far?
- Does the string look like an email address?
- Can the product accept or verify it right now?
Those questions have different costs and different owners. React can answer the first. A small client function can answer the second. The API answers the third. When all three run inside onChange, every keystroke becomes a mini distributed system. Thats a lot of machinery for one input.
The timing bug is common: the user types alex@example.com, then quickly changes it. The request for the old value returns last and sets an error for the new value. In a shared QA flow, strings such as temp mailid or tempail can make debugging even more confusing if the UI does not show which value was checked.
Keep local validation instant
Start with a deliberately boring local check. It should be fast, predictable, and not pretend to prove that a mailbox exists.
export function getEmailFormatError(value) {
const email = value.trim();
if (!email) return "Enter your email address.";
if (!email.includes("@") || email.endsWith("@")) {
return "Enter a complete email address.";
}
return null;
}
This is not intended to replace server-side validation. It is there to give immediate guidance without blocking the next keystroke. I usually run it on blur, and optionally after the field has been touched. Validating on every key can be okay for a tiny form, but it becomes noisy fast.
For broader signup design, the ideas in safer recovery email evidence are useful: the email step should leave evidence that explains what happened, not just a red label. It also connects with boundaries around OTP email flows when verification is part of the same journey.
Cancel stale requests
When the format is valid, debounce the remote check and cancel the previous request. AbortController is built into modern browsers and keeps the intent visible in code.
import { useEffect, useState } from "react";
export function useEmailAvailability(email) {
const [state, setState] = useState({ status: "idle", message: "" });
useEffect(() => {
const value = email.trim();
const formatError = getEmailFormatError(value);
if (formatError || !value) {
setState({ status: value ? "invalid" : "idle", message: formatError || "" });
return;
}
const controller = new AbortController();
setState({ status: "checking", message: "Checking…" });
const timer = setTimeout(async () => {
try {
const response = await fetch(`/api/email-check?value=${encodeURIComponent(value)}`, {
signal: controller.signal
});
if (!response.ok) throw new Error("Email check failed");
const result = await response.json();
setState(result.available
? { status: "available", message: "Email is ready to use." }
: { status: "unavailable", message: "Try another email address." });
} catch (error) {
if (error.name !== "AbortError") {
setState({ status: "unknown", message: "We could not check this yet." });
}
}
}, 350);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [email]);
return state;
}
The cleanup is the important bit. A request for an old value should not be allowed to decide the current UI. It also stops timers when the component unmounts, which prevents a sneaky warning and wasted work.
Model decisions instead of booleans
Avoid a pair of flags such as isLoading and isValid. They permit awkward combinations: loading and valid, invalid and available, or nothing checked but valid. A status union is clearer:
type EmailStatus =
| "idle" | "invalid" | "checking"
| "available" | "unavailable" | "unknown";
Each status can map to one message, one visual treatment, and one submit rule. unknown is especially important. A network failure is not proof that the email is bad, so the product should explain the retry path instead of blaming the user.
A small shipping checklist
- Keep typing state local and cheap.
- Debounce remote checks by a modest, consistent delay.
- Abort work for values that are no longer current.
- Show whether the result is invalid, unavailable, or simply unknown.
- Keep server validation authoritative at submit time.
- Test slow responses, out-of-order responses, and unmounts.
The best signup validation is almost invisible: the field responds immediately, the network work has boundaries, and every decision has a reason. That is a much cleaner feature to ship, and a much kinder form to use.
Top comments (0)