Verifica delle email in React
Questo tutorial costruisce un hook di verifica delle email in tempo reale per React. Mentre la persona scrive il proprio indirizzo, l'hook applica il debounce alle richieste verso il tuo backend, che è quello che chiama Mailbeam. Il risultato governa gli stati in linea dell'interfaccia: indicatore di caricamento, messaggio di errore o segno di spunta verde.
Che cosa costruirai
- Un hook
useEmailVerificationcon debounce - Un endpoint di verifica (Route Handler di Next.js o Express)
- Un componente
EmailInputaccessibile con stato in linea - La sua integrazione in un modulo di registrazione
Prerequisiti
- Un progetto React 18 o superiore
- Un backend a cui puoi aggiungere una rotta
- Una chiave API di Mailbeam (iscriviti gratis)
Passo 1 — Crea la rotta API
L'hook chiama il tuo backend, e il backend chiama Mailbeam. Così la chiave API resta sul server.
Route Handler di Next.js:
// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
// Non ci sono ancora SDK: questo è il wrapper di 12 righe di /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: "il campo email è obbligatorio" }, { status: 400 });
}
try {
const result = await verifyEmail(email);
return NextResponse.json(result);
} catch {
// Lascia passare: restituisci una risposta "valida" per non bloccare il modulo
return NextResponse.json({ valid: true, score: 50, reason: null });
}
}Equivalente in Express:
// routes/verifyEmail.js
// Non ci sono ancora SDK: questo è il wrapper di 12 righe di /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 }); // lascia passare
}
});Passo 2 — Costruisci l'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: "Controlla il formato dell'email.",
no_mx_records: "Questo dominio non sembra accettare posta.",
smtp_rejected: "Questo indirizzo email non sembra esistere.",
disposable_domain: "Usa un'email permanente, non una temporanea.",
role_address: "Usa un indirizzo email personale.",
catch_all_unverifiable: "Non siamo riusciti a verificare del tutto questo indirizzo; controllalo.",
};
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(() => {
// Azzera se l'email è vuota o sembra incompleta
if (!email || !email.includes("@") || !email.includes(".")) {
setStatus("idle");
setResult(null);
return;
}
setStatus("loading");
const timer = setTimeout(async () => {
// Annulla qualsiasi richiesta in volo
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;
// Lascia passare: un errore di rete non deve bloccare il modulo
setStatus("idle");
}
}, debounceMs);
return () => {
clearTimeout(timer);
abortRef.current?.abort();
};
}, [email, debounceMs, minScore]);
const errorMessage =
status === "invalid" && result?.reason
? (REASON_MESSAGES[result.reason] ?? "Inserisci un indirizzo email valido.")
: null;
return { status, result, errorMessage };
}Passo 3 — Crea il 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 = "Indirizzo 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}
/>
{/* Indicatore di stato */}
<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>
);
}Passo 4 — Usalo in un modulo di registrazione
// 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();
// Blocca l'invio mentre la verifica carica o è fallita
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">Crea account</h1>
<EmailInput
value={email}
onChange={(e) => setEmail(e.target.value)}
verification={verification}
/>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</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"
>
Crea account
</button>
</form>
</main>
);
}Buone pratiche
| Pratica | Perché |
|---|---|
| Debounce di 600 ms | Aspetta che la persona finisca di scrivere prima di partire con la richiesta |
| Interrompere le richieste in volo | Evita corse critiche quando l'email cambia in fretta |
| Lasciar passare davanti agli errori di fetch | I problemi di rete non devono bloccare il modulo |
aria-invalid + aria-describedby | Stato di errore accessibile ai lettori di schermo |
Condizionare l'invio a "valid" | "idle" | Non permettere l'invio a metà verifica |
Lista di controllo per la produzione
- La rotta API vive solo sul server (la chiave non è nel bundle del client)
- Debounce regolato sulla tua esperienza d'uso (600 ms è un buon punto di partenza)
- I messaggi di errore sono azionabili («usa un'email permanente» invece di «email non valida»)
- Modulo accessibile:
aria-invalid,aria-describedbye ruoli definiti - Percorso «lascia passare» provato: un errore di fetch deve comunque permettere l'invio del modulo