Vérification d'e-mails en React
Ce tutoriel construit un hook de vérification d'e-mails en temps réel pour React. Pendant que l'utilisateur saisit son adresse, le hook temporise les requêtes vers votre back-end, qui appelle Mailbeam. Le résultat pilote les états de l'interface en ligne : indicateur de chargement, message d'erreur ou coche verte.
Ce que vous allez construire
- Un hook
useEmailVerificationtemporisé - Un endpoint de vérification (Route Handler Next.js ou Express)
- Un composant
EmailInputaccessible, avec état en ligne - Son intégration dans un formulaire d'inscription
Prérequis
- Un projet React 18+
- Un back-end auquel vous pouvez ajouter une route
- Une clé d'API Mailbeam (inscription gratuite)
Étape 1 — Créer la route d'API
Le hook appelle votre back-end, qui appelle Mailbeam. La clé d'API reste ainsi côté serveur.
Route Handler Next.js :
// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
// Pas encore de SDK : voici le wrapper de 12 lignes 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: "email is required" }, { status: 400 });
}
try {
const result = await verifyEmail(email);
return NextResponse.json(result);
} catch {
// Échec permissif — on renvoie une réponse « valide » pour ne pas bloquer le formulaire
return NextResponse.json({ valid: true, score: 50, reason: null });
}
}Équivalent Express :
// routes/verifyEmail.js
// Pas encore de SDK : voici le wrapper de 12 lignes 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 }); // échec permissif
}
});Étape 2 — Construire le 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: "Merci de vérifier le format de l'adresse.",
no_mx_records: "Ce domaine ne semble pas accepter de courrier.",
smtp_rejected: "Cette adresse e-mail ne semble pas exister.",
disposable_domain: "Merci d'utiliser une adresse permanente, pas une adresse temporaire.",
role_address: "Merci d'utiliser une adresse e-mail personnelle.",
catch_all_unverifiable: "Nous n'avons pas pu vérifier complètement cette adresse — merci de la relire.",
};
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(() => {
// Remise à zéro si l'adresse est vide ou visiblement incomplète
if (!email || !email.includes("@") || !email.includes(".")) {
setStatus("idle");
setResult(null);
return;
}
setStatus("loading");
const timer = setTimeout(async () => {
// Annule toute requête encore en vol
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;
// Échec permissif — ne bloquez pas le formulaire sur une erreur de fetch
setStatus("idle");
}
}, debounceMs);
return () => {
clearTimeout(timer);
abortRef.current?.abort();
};
}, [email, debounceMs, minScore]);
const errorMessage =
status === "invalid" && result?.reason
? (REASON_MESSAGES[result.reason] ?? "Merci de fournir une adresse e-mail valide.")
: null;
return { status, result, errorMessage };
}Étape 3 — Créer le composant 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 = "Adresse e-mail",
...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}
/>
{/* Indicateur d'état */}
<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>
);
}Étape 4 — L'utiliser dans un formulaire d'inscription
// 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();
// Bloque l'envoi pendant la vérification ou après un échec
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">Créer un compte</h1>
<EmailInput
value={email}
onChange={(e) => setEmail(e.target.value)}
verification={verification}
/>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Mot de passe
</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"
>
Créer le compte
</button>
</form>
</main>
);
}Bonnes pratiques
| Pratique | Pourquoi |
|---|---|
| Temporiser à 600 ms | Attend que l'utilisateur ait fini de taper avant de lancer une requête |
| Annuler les requêtes en vol | Évite les conditions de course quand l'adresse change vite |
| Échec permissif sur les erreurs de fetch | Un souci réseau ne doit pas bloquer le formulaire |
aria-invalid et aria-describedby | État d'erreur accessible aux lecteurs d'écran |
Conditionner l'envoi à "valid" | "idle" | Interdit l'envoi en pleine vérification |
Liste de contrôle avant la mise en production
- La route d'API ne vit que côté serveur (clé d'API absente du bundle client)
- Temporisation ajustée à votre expérience utilisateur (600 ms est un bon point de départ)
- Messages d'erreur exploitables (« utilisez une adresse permanente » plutôt que « adresse invalide »)
- Formulaire accessible :
aria-invalid,aria-describedby, rôles définis - Échec permissif testé : une erreur de fetch doit toujours permettre l'envoi