Throwaway addresses from services like Mailinator or 10 Minute Mail pass every basic regex check. They have a valid format, a real domain, and a working inbox, just one that disappears in an hour. If your signup form feeds a trial, a gated download, or a lead list, those signups add noise and sometimes abuse.
Here's a practical way to catch them in a React form: a debounced check while the user types, a small backend proxy so your API key never reaches the browser, and a server-side re-check on submit.
The architecture
Three pieces, and the order matters:
- React form that checks the email shortly after the user stops typing
- Your own backend route that calls the validation API, so the key stays secret
- A final check on submit on the server, because anything running in the browser can be bypassed
Skip piece 2 and you'll end up shipping an API key in your frontend bundle. Skip piece 3 and anyone can post straight to your signup endpoint and avoid the check entirely.
Step 1: A small debounce hook
Without debouncing, you'd fire a request on every keystroke.
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Step 2: The form component
export default function SignupForm() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState('idle'); // idle | checking | ok | blocked
const debouncedEmail = useDebounce(email, 600);
useEffect(() => {
// Skip empty or obviously malformed input
if (!debouncedEmail || !/^\S+@\S+\.\S+$/.test(debouncedEmail)) {
setStatus('idle');
return;
}
const controller = new AbortController();
setStatus('checking');
fetch('/api/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: debouncedEmail }),
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => setStatus(data.allowed ? 'ok' : 'blocked'))
.catch((err) => {
// Ignore cancelled requests; fail open on real errors
if (err.name !== 'AbortError') setStatus('ok');
});
// Cancel the in-flight request if the email changes again
return () => controller.abort();
}, [debouncedEmail]);
return (
<form>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
{status === 'checking' && <p>Checking...</p>}
{status === 'blocked' && (
<p>Please use a permanent email address, temporary inboxes aren't accepted.</p>
)}
<button type="submit" disabled={status === 'blocked' || status === 'checking'}>
Sign up
</button>
</form>
);
}
Two details worth noticing. The AbortController cancels stale requests so a slow response for an old value can't overwrite the result for the current one. And errors fail open: if the validation service has a hiccup, you let the signup through rather than blocking a real user.
Step 3: The backend proxy
A minimal Express route that keeps the key on the server:
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/check-email', async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ allowed: false });
try {
const url =
`https://api.gamalogic.com/v1/validate` +
`?email=${encodeURIComponent(email)}` +
`&api_key=${process.env.GAMALOGIC_API_KEY}`;
const response = await fetch(url);
const result = await response.json();
res.json({ allowed: isAcceptable(result) });
} catch (err) {
console.error('Validation error:', err.message);
res.json({ allowed: true }); // fail open
}
});
// Placeholder logic: check the API docs for the actual response fields
// and map them here (disposable flag, validity, risk level, etc.)
function isAcceptable(result) {
if (result.disposable) return false;
if (result.valid === false) return false;
return true;
}
app.listen(3000);
I'm calling Gamalogic's real-time validation API here, which runs syntax, domain, SMTP and risk checks in one request. The field names in isAcceptable are placeholders, so check the response schema in the API documentation and adjust the mapping to match.
Step 4: Re-check on submit, server-side
The client-side check is for user experience only. Your actual signup handler should validate again:
app.post('/api/signup', async (req, res) => {
const { email } = req.body;
const allowed = await checkEmailServerSide(email); // same logic as above
if (!allowed) {
return res.status(422).json({ error: 'Please use a permanent email address.' });
}
// ...create the account
res.json({ ok: true });
});
Without this, a bot or a curious user can skip your React form and post directly to the endpoint.
Things to watch out for
- False positives. No detection is perfect. Keep the error message polite and let users fix their input, rather than silently rejecting them.
- Latency. Real-time checks add a delay. The debounce and the "Checking..." state keep the form feeling responsive, and you should test latency against your own traffic and region.
- Rate limits and cost. Every keystroke pause is a billable call on most services. The debounce helps, and you can add a simple cache for addresses you've already checked.
- Don't over-block. Some people use privacy aliases from legitimate services (like Apple's hide-my-email) that aren't throwaway inboxes. Decide what your policy is before you ship it.
Why use an API instead of a blocklist
A hand-maintained list of disposable domains goes stale quickly, since new services appear all the time, and it can't confirm whether a mailbox actually exists. A validation API handles the domain matching, the live SMTP check, and the risk scoring together. I work with Gamalogic on this side of things, and its fake and disposable email checker covers this use case. ZeroBounce and Kickbox have comparable APIs if you're already integrated with one of them.
Top comments (0)