Verificación de email en React
Este tutorial construye un hook de verificación de email en tiempo real para React. Mientras la persona escribe su dirección, el hook aplica un debounce a las peticiones a tu backend, que es quien llama a Mailbeam. El resultado gobierna los estados en línea de la interfaz: indicador de carga, mensaje de error o marca verde.
Qué vas a construir
- Un hook
useEmailVerificationcon debounce - Un endpoint de verificación (Route Handler de Next.js o Express)
- Un componente
EmailInputaccesible con estado en línea - Su integración en un formulario de registro
Requisitos previos
- Un proyecto de React 18 o superior
- Un backend al que puedas añadir una ruta
- Una clave de API de Mailbeam (regístrate gratis)
Paso 1 — Crea la ruta de API
El hook llama a tu backend y este llama a Mailbeam. Así la clave de API se queda en el servidor.
Route Handler de Next.js:
// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
// Todavía no hay SDK: este es el envoltorio de 12 líneas de /docs/quickstart.
import { verifyEmail } from "./lib/mailbeam";
export async function POST(request: Request) {
const { email } = await request.json();
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "el campo email es obligatorio" }, { status: 400 });
}
try {
const result = await verifyEmail(email);
return NextResponse.json(result);
} catch {
// Falla en abierto: devuelve una respuesta "válida" para no bloquear el formulario
return NextResponse.json({ valid: true, score: 50, reason: null });
}
}Equivalente en Express:
// routes/verifyEmail.js
// Todavía no hay SDK: este es el envoltorio de 12 líneas de /docs/quickstart.
import { verifyEmail } from "./lib/mailbeam";
router.post("/api/verify-email", async (req, res) => {
const { email } = req.body;
try {
const result = await verifyEmail(email);
res.json(result);
} catch {
res.json({ valid: true, score: 50, reason: null }); // falla en abierto
}
});Paso 2 — Construye el hook
// hooks/useEmailVerification.ts
import { useState, useEffect, useRef } from "react";
export type VerificationStatus = "idle" | "loading" | "valid" | "invalid";
export interface VerificationResult {
valid: boolean;
score: number;
reason: string | null;
}
export interface UseEmailVerificationReturn {
status: VerificationStatus;
result: VerificationResult | null;
errorMessage: string | null;
}
const REASON_MESSAGES: Record<string, string> = {
invalid_syntax: "Revisa el formato del email.",
no_mx_records: "Este dominio no parece aceptar correo.",
smtp_rejected: "Esta dirección de email no parece existir.",
disposable_domain: "Usa un email permanente, no uno temporal.",
role_address: "Usa una dirección de email personal.",
catch_all_unverifiable: "No hemos podido verificar del todo esta dirección; compruébala.",
};
export function useEmailVerification(
email: string,
{ debounceMs = 600, minScore = 60 }: { debounceMs?: number; minScore?: number } = {}
): UseEmailVerificationReturn {
const [status, setStatus] = useState<VerificationStatus>("idle");
const [result, setResult] = useState<VerificationResult | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
// Reinicia si el email está vacío o parece incompleto
if (!email || !email.includes("@") || !email.includes(".")) {
setStatus("idle");
setResult(null);
return;
}
setStatus("loading");
const timer = setTimeout(async () => {
// Cancela cualquier petición en vuelo
abortRef.current?.abort();
abortRef.current = new AbortController();
try {
const res = await fetch("/api/verify-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: abortRef.current.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: VerificationResult = await res.json();
setResult(data);
setStatus(data.valid && data.score >= minScore ? "valid" : "invalid");
} catch (err) {
if ((err as Error).name === "AbortError") return;
// Falla en abierto: un error de red no debería bloquear el formulario
setStatus("idle");
}
}, debounceMs);
return () => {
clearTimeout(timer);
abortRef.current?.abort();
};
}, [email, debounceMs, minScore]);
const errorMessage =
status === "invalid" && result?.reason
? (REASON_MESSAGES[result.reason] ?? "Escribe una dirección de email válida.")
: null;
return { status, result, errorMessage };
}Paso 3 — Crea el componente EmailInput
// components/EmailInput.tsx
import { useId } from "react";
import { type UseEmailVerificationReturn } from "@/hooks/useEmailVerification";
interface EmailInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
verification: UseEmailVerificationReturn;
label?: string;
}
export function EmailInput({
verification,
label = "Dirección de email",
...inputProps
}: EmailInputProps) {
const id = useId();
const errorId = `${id}-error`;
const { status, errorMessage } = verification;
return (
<div className="space-y-1">
<label
htmlFor={id}
className="block text-sm font-medium text-foreground"
>
{label}
</label>
<div className="relative">
<input
id={id}
type="email"
autoComplete="email"
aria-invalid={status === "invalid"}
aria-describedby={status === "invalid" ? errorId : undefined}
className={`
w-full rounded-lg border px-3 py-2 pr-9 text-sm bg-background
focus:outline-none focus:ring-2 focus:ring-ring
${status === "invalid" ? "border-destructive" : ""}
${status === "valid" ? "border-green-500" : "border-border"}
`}
{...inputProps}
/>
{/* Indicador de estado */}
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm"
aria-hidden="true"
>
{status === "loading" && (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
)}
{status === "valid" && <span className="text-green-500">✓</span>}
{status === "invalid" && <span className="text-destructive">✗</span>}
</span>
</div>
{errorMessage && (
<p id={errorId} role="alert" className="text-xs text-destructive">
{errorMessage}
</p>
)}
</div>
);
}Paso 4 — Úsalo en un formulario de registro
// app/signup/page.tsx
"use client";
import { useState } from "react";
import { EmailInput } from "@/components/EmailInput";
import { useEmailVerification } from "@/hooks/useEmailVerification";
export default function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const verification = useEmailVerification(email);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Bloquea el envío mientras la verificación carga o ha fallado
if (verification.status === "loading" || verification.status === "invalid") {
return;
}
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
window.location.href = "/dashboard";
}
}
const canSubmit =
verification.status === "valid" || verification.status === "idle";
return (
<main className="flex min-h-screen items-center justify-center px-4">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-bold">Crear cuenta</h1>
<EmailInput
value={email}
onChange={(e) => setEmail(e.target.value)}
verification={verification}
/>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Contraseña
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-border px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<button
type="submit"
disabled={!canSubmit || !email || !password}
className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
Crear cuenta
</button>
</form>
</main>
);
}Buenas prácticas
| Práctica | Por qué |
|---|---|
| Debounce de 600 ms | Espera a que la persona termine de escribir antes de lanzar la petición |
| Abortar las peticiones en vuelo | Evita condiciones de carrera cuando el email cambia rápido |
| Fallar en abierto ante errores de fetch | Los problemas de red no deberían bloquear el formulario |
aria-invalid + aria-describedby | Estado de error accesible para lectores de pantalla |
Condicionar el envío a "valid" | "idle" | No permitas enviar a mitad de la verificación |
Checklist de producción
- La ruta de API vive solo en el servidor (la clave no está en el bundle del cliente)
- Debounce ajustado a tu experiencia de uso (600 ms es un buen punto de partida)
- Los mensajes de error son accionables («usa un email permanente» en vez de «email inválido»)
- Formulario accesible:
aria-invalid,aria-describedbyy roles definidos - Fallo en abierto probado: un error de fetch debe seguir permitiendo enviar el formulario