Formulaires 2 — validation & erreurs
Formulaires 1 — contrôlé ou non t'a appris à collecter ce que l'utilisateur tape. Ce module s'occupe de la moitié la plus difficile : décider si c'est acceptable, le dire au bon moment et d'une manière qu'il perçoit, puis survivre à l'envoi. C'est là que se joue la différence entre un formulaire qui « marche sur la démo » et un formulaire qu'on met en production — précisément ce qu'on regarde dans un test technique frontend.
Trois lieux de validation, trois rôles bien distincts
La validation n'a pas un endroit, elle en a trois, et ces trois endroits ne
servent pas à la même chose. Le navigateur d'abord : depuis HTML5, l'attribut
required, type="email", min, minlength ou
pattern suffit à ce qu'il refuse tout seul la soumission — gratuit, instantané, opérant
même avant que ton JavaScript ait fini de charger. Ton JavaScript côté client
ensuite, pour les règles que le HTML ne sait pas dire (« la date doit être dans le futur », « la
clinique doit offrir le service choisi ») et pour contrôler l'expérience : quel message, où, quand,
dans quel ton. Le serveur enfin, seul endroit où le code s'exécute sur une machine
que l'utilisateur ne contrôle pas — supprimer un required dans les outils de
développement prend deux secondes, poster sur ton API avec curl sans jamais ouvrir ta
page en prend dix.
| Lieu | Rôle principal | Contournable ? | Coût pour toi |
|---|---|---|---|
| Navigateur (attributs HTML) | Filet de sécurité gratuit, fonctionne sans JS | Oui, trivialement | Quasi nul |
| Client (ton JS / React) | Confort, feedback immédiat, messages soignés | Oui, trivialement | Moyen |
| Serveur | Sécurité et intégrité des données | Non | Moyen — mais non négociable |
-
navigateurLes attributs HTML
required,type="email",min,patternretiré en deux secondes dans les outils de dév - clientTon validateur JSles règles que le HTML ne sait pas dire, et le soin du messagec'est du code que l'utilisateur possède
- serveurLe contrôle serveurles mêmes règles, sur une machine que l'utilisateur ne contrôle pasla seule barrière réelle
curl qui n'ouvre jamais ta page.
Seul le troisième protège les données. C'est pour ça qu'on écrit le validateur comme une
fonction pure : le même fichier peut tourner aux deux étages, sans que les
règles aient une chance de diverger.
Le formulaire demande un nom, un email, une date et un service. Imagine qu'on ne valide que côté client la règle « la date doit être dans le futur ». Un utilisateur malicieux — ou simplement un script mal réglé — poste une date en 1998. Le serveur l'accepte, la base contient un rendez-vous impossible, le tableau de bord de la clinique affiche une ligne aberrante, et le rappel automatique plante en calculant « dans −10 000 jours ». Aucune malveillance spectaculaire : juste une règle qui n'existait que dans le navigateur. C'est comme ça que les données pourrissent.
La validation native du navigateur : puissante, gratuite, et souvent désactivée
Ce que le navigateur sait faire tout seul est réellement impressionnant, et on te posera la question. Les attributs de contrainte s'écrivent sur les champs, et il les fait respecter à la soumission.
<form action="/api/appointments" method="post">
<label for="nom">Nom complet</label>
<input id="nom" name="fullName" type="text" required minlength="2" />
<label for="email">Courriel</label>
<!-- type="email" impose une forme "quelque chose@quelque chose" -->
<input id="email" name="email" type="email" required />
<label for="date">Date souhaitée</label>
<!-- min : le navigateur refuse toute date antérieure (en vrai code,
on générerait la date du jour au lieu de l'écrire en dur) -->
<input id="date" name="date" type="date" required min="2026-01-01" />
<label for="tel">Téléphone</label>
<!-- pattern : une expression régulière que la valeur doit satisfaire -->
<input id="tel" name="phone" type="tel" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" />
<button type="submit">Réserver</button>
</form>
Zéro ligne de JavaScript, et pourtant : champs obligatoires respectés, email grossièrement vérifié,
dates passées refusées, format de téléphone imposé. Le navigateur bloque la soumission, met le focus
sur le premier champ fautif et affiche un message. Et type="date" / type="tel"
changent aussi le clavier sur téléphone : la sémantique du champ sert l'ergonomie autant que la
validation.
Derrière ces attributs, il y a une véritable API, l'API de contrainte
(Constraint Validation API) : une propriété validity qui détaille
pourquoi c'est invalide (valueMissing, typeMismatch,
patternMismatch, rangeUnderflow…), checkValidity() qui renvoie
un booléen, setCustomValidity('mon message') pour imposer ton texte, et le
pseudo-sélecteur CSS :invalid pour styler l'état fautif.
Alors pourquoi tant d'équipes désactivent-elles tout ça avec l'attribut noValidate posé
sur le <form> ? Quatre raisons, qu'il vaut la peine de savoir citer :
- Le style est hors de contrôle. La bulle native est dessinée par le navigateur : ni ta police, ni tes couleurs, ni ta position. Sur un produit avec une charte graphique, elle jure.
- Les messages ne sont pas les tiens. « Veuillez renseigner ce champ » est correct mais générique, et varie d'un navigateur à l'autre — impossible de tenir un ton de marque.
- Elle ne montre qu'une erreur à la fois. Le navigateur s'arrête au premier champ fautif : dix champs, dix allers-retours. Un résumé « voici les 4 champs à corriger » est plus humain.
- Elle ne sait pas exprimer les règles croisées. « Le mot de passe et sa confirmation doivent correspondre », « si le service est un suivi post-opératoire, la date de chirurgie devient obligatoire » : aucun attribut HTML ne dit ça. Dès qu'un champ dépend d'un autre, on repasse en JS.
Attention à la nuance : noValidate désactive l'interface native (bulles,
blocage de la soumission), pas les attributs eux-mêmes. required reste annoncé par les
lecteurs d'écran, type="email" change encore le clavier mobile, min borne
encore le sélecteur de date. Le bon réflexe : garder les attributs pour leur sémantique et
leur ergonomie, mais reprendre l'affichage des erreurs à la main. Cette distinction entre
« la balise dit ce que la chose EST » et « le CSS/JS dit à quoi ça ressemble » est le fil rouge du
module HTML sémantique & accessibilité.
Tes messages d'erreur maison ne s'affichent jamais : quand un champ est vide, on voit une
petite bulle grise du navigateur, et rien d'autre. Un point d'arrêt le confirme —
handleSubmit n'est même pas appelé. Pourquoi ?
<form onSubmit={handleSubmit}>
<input name="fullName" type="text" required minlength="2" />
<input name="email" type="email" required />
<button type="submit">Réserver</button>
</form>
Parce que la validation native s'exécute avant ton code. Au clic, le
navigateur vérifie d'abord ses contraintes (required,
type="email", minlength) ; s'il en trouve une violée, il annule
purement et simplement la soumission, met le focus sur le premier champ fautif et affiche sa
bulle. L'événement submit n'est jamais émis : ton handleSubmit
n'est pas « ignoré », il n'a jamais été convoqué.
Correctif : noValidate sur le <form>. Et surtout, ne fais
pas ce que fait l'autre moitié des développeurs — supprimer les attributs pour que
leur JavaScript reprenne la main. noValidate ne coupe que l'interface
native ; les attributs restent en place et continuent de travailler pour toi :
required reste annoncé par les lecteurs d'écran, type="email"
change encore le clavier mobile. Tu gardes la sémantique, tu reprends seulement l'affichage.
Écrire la validation en JS : une fonction PURE, valeurs → erreurs
Voici le cœur du module, et le patron que tu dois pouvoir écrire de mémoire. L'idée est d'une
simplicité désarmante : une fonction qui prend l'objet des valeurs et renvoie un objet des
erreurs. Rien d'autre — elle ne touche pas au DOM, n'appelle pas setState, ne
connaît même pas l'existence de React. On dit qu'une fonction est pure quand, pour
les mêmes entrées, elle rend toujours la même sortie et ne provoque aucun effet de bord. C'est le même
mot que dans « le rendu React doit être pur » : même exigence, même bénéfice.
Trois raisons de faire ce choix, toutes défendables en entrevue :
-
Testable. Des dizaines de cas vérifiés sans monter un composant, sans clic, sans
navigateur :
expect(validate({ fullName: '' }).fullName).toBeDefined(), et c'est plié (module Vitest & Testing Library). - Réutilisable. La même fonction tourne dans le navigateur pour le confort et sur le serveur pour la sécurité — code « isomorphe » : une seule définition des règles, deux lieux d'exécution, zéro divergence possible. En Next.js, où le même fichier TypeScript s'exécute des deux côtés, c'est presque offert.
- Lisible. « Quelles sont les règles de ce formulaire ? » se répond en ouvrant un fichier, au lieu de reconstituer des règles éparpillées dans six gestionnaires d'événements.
Le validateur pur
function validate(values) {
const errors = {};
if (!values.fullName?.trim()) errors.fullName = 'Le nom est requis.';
if (!/.+@.+\..+/.test(values.email ?? '')) errors.email = 'Courriel invalide.';
return errors; // {} = valide
}
Une fonction pure, testable, totalement indépendante de React : on lui donne les valeurs, elle rend
un dictionnaire { field: message }, et un objet vide signifie « tout va bien ». C'est
le patron qu'attendent les évaluateurs quand ils te demandent « ajoute la validation » : ils
regardent si les règles vivent dans un seul endroit isolé, ou si elles sont éparpillées dans le JSX.
Deux détails de syntaxe au passage : ?. est l'accès optionnel
(values.fullName?.trim() rend undefined au lieu de planter si la clé est
absente) et ?? le coalescent nul (values.email ?? '' vaut la chaîne vide si
la valeur est nulle). Ensemble, ils rendent le validateur robuste face à un objet incomplet — ce qui
arrive dès qu'un champ n'a jamais été touché. Voici la version réelle et typée pour RendezVous :
chaque if est une phrase du cahier des charges.
export type AppointmentValues = {
fullName: string;
email: string;
date: string; // format "AAAA-MM-JJ", celui d'un <input type="date">
service: string;
};
// Un dictionnaire champ → message. Les clés possibles sont celles des valeurs,
// et Partial dit "chaque clé est facultative" : un champ valide n'a pas d'entrée.
export type AppointmentErrors = Partial<Record<keyof AppointmentValues, string>>;
export function validateAppointment(v: AppointmentValues): AppointmentErrors {
const errors: AppointmentErrors = {};
// 1. Nom : présent, et pas juste des espaces (d'où le .trim()).
if (!v.fullName.trim()) {
errors.fullName = 'Entre ton nom complet, comme sur ta carte d’assurance maladie.';
} else if (v.fullName.trim().length < 2) {
errors.fullName = 'Le nom doit contenir au moins 2 caractères.';
}
// 2. Courriel : on reste volontairement PERMISSIF (voir la note plus bas).
if (!v.email.trim()) {
errors.email = 'Entre un email : c’est là qu’on envoie ta confirmation.';
} else if (!/.+@.+\..+/.test(v.email)) {
errors.email = 'Ce email semble incomplet. Exemple : nom@exemple.ca';
}
// 3. Date : présente ET dans le futur. Le "T00:00" force l'heure locale.
if (!v.date) {
errors.date = 'Choisis une date de rendez-vous.';
} else if (new Date(v.date + 'T00:00') < new Date()) {
errors.date = 'Choisis une date à venir : on ne peut pas réserver dans le passé.';
}
// 4. Service : un choix parmi la liste. Vide = rien de sélectionné.
if (!v.service) {
errors.service = 'Sélectionne le type de consultation souhaité.';
}
return errors; // {} = tout est valide
}
Quarante lignes, et toutes les règles métier du formulaire tiennent dedans. Remarque le
else if : un seul message par champ, le plus pertinent — dire à la fois
« le nom est requis » et « le nom est trop court » serait bruyant. Pour relire ce que veut dire
Partial<Record<…>>, va voir
Lire les types TypeScript.
Tu trouveras sur internet des regex de email longues de 400 caractères, censées implémenter la norme officielle. Ne les utilise pas : la spécification est absurdement large (guillemets, accents et domaines exotiques sont légaux) et aucune regex ne peut te dire si une adresse existe. La seule preuve qu'un email est valide, c'est qu'un message envoyé à cette adresse arrive. Donc : une regex très simple pour attraper les fautes de frappe évidentes, et pour le reste un email de confirmation. Refuser une adresse valide est un bug bien plus grave qu'accepter une adresse douteuse.
Tu connais déjà ce patron, sans forcément l'avoir nommé. Dans ton app mobile, l'architecture
distingue deux familles de validateurs : ceux qui renvoient un résultat
(Result-based, du style { valide: false, message: '…' }), utilisés par les
hooks et l'interface, et ceux qui lancent une exception (throwing,
ValidationError), utilisés au moment d'écrire en base. Ce n'est pas une incohérence,
c'est exactement la distinction de ce module : le Result-based, c'est la couche confort —
l'interface veut afficher un message, pas gérer un try/catch ; le throwing,
c'est la couche garde-fou — écrire une donnée invalide doit faire un bruit impossible à ignorer.
Le validateAppointment ci-dessus est la version web de ton validateur Result-based. Et le
contrôle équivalent dans la route serveur, c'est la version throwing. Mêmes règles, deux postures.
Le formulaire part au serveur même quand tous les champs sont vides — et pourtant
validate produit bien ses messages d'erreur. Où est la faute ?
function validate(values) {
const errors = {};
if (!values.fullName.trim()) errors.fullName = 'Le nom est requis.';
if (!values.email.trim()) errors.email = 'Le email est requis.';
return errors; // {} = valide
}
const isValid = validate(values);
if (isValid) send(values);
validate rend un objet, pas un booléen — et en JavaScript,
tout objet est truthy, {} compris. if (isValid) est donc
vrai dans les deux cas, formulaire valide comme formulaire vide : l'envoi part toujours, et
la validation ne sert strictement à rien. La convention « objet vide = valide » est
excellente — elle transporte le verdict et les messages — mais elle se paie d'une
conversion explicite : Object.keys(errors).length === 0.
Le nom de la variable a sa part de responsabilité : isValid promet un booléen et
contient un dictionnaire. Appelle-la errors, et le if (errors) te
sautera aux yeux. À garder en tête plus largement : sont falsy
0, '', null, undefined,
NaN et false — un objet vide et un tableau vide, non.
Quand valider : le moment compte autant que la règle
Tu peux avoir la validation la plus juste du monde et rendre ton formulaire détestable, simplement en la déclenchant au mauvais moment. Trois instants possibles, et le bon choix est une combinaison, pas un des trois.
Au submit : le minimum vital, le comportement par défaut du web. Avantage : l'utilisateur n'est jamais dérangé pendant la saisie. Inconvénient : sur un formulaire long, il découvre quatre problèmes après avoir cru avoir fini, et doit remonter chercher les champs fautifs. Au blur (le champ perd le focus, l'utilisateur passe au suivant) : le meilleur compromis, et de loin. Le signal implicite est clair — « j'ai fini avec ce champ » — et lui dire à ce moment-là « il manque le @ » arrive pile quand il peut encore corriger, sans revenir dix champs en arrière.
À la frappe (onChange, à chaque caractère). Utilisé seul, c'est
hostile, et il faut comprendre précisément pourquoi. Le premier caractère du email
est un p ; ton validateur constate l'absence de @ et affiche en rouge
« Courriel invalide ». L'utilisateur n'a rien fait de mal : il a commencé. Tu viens de le
gronder pour avoir obéi. Et ça continue à chaque lettre, jusqu'à ce que la personne se demande si elle
sait écrire son propre email. C'est un cas d'école d'anxiété fabriquée par du code.
Ne crie pas pendant que l'utilisateur tape. Valide au blur, puis en direct SEULEMENT après une première erreur sur ce champ. La première fois, tu attends qu'il ait fini le champ pour lui parler ; une fois le champ signalé en erreur, la revalidation à chaque frappe devient légitime, parce qu'il essaie de corriger et que voir l'erreur disparaître est une récompense immédiate. Le feedback change de nature : de reproche, il devient guidage.
Pour implémenter ça, il te faut une information supplémentaire par champ : l'utilisateur l'a-t-il déjà
quitté au moins une fois ? C'est l'état touched — un second objet, parallèle
à values, où l'on note { email: true } dès que le champ perd le focus.
Pourquoi un état séparé plutôt que « le champ est vide donc jamais touché » ? Parce qu'un champ peut
être vide parce qu'on ne l'a jamais visité, ou parce qu'on l'a visité, rempli, puis effacé. Dans le
premier cas, se taire ; dans le second, parler. values dit ce que contient le
formulaire, touched dit où l'utilisateur est passé : deux dimensions
indépendantes, donc deux états.
const [values, setValues] = useState<AppointmentValues>({ fullName: '', email: '', date: '', service: '' });
// Quels champs l'utilisateur a-t-il déjà quittés ? (touched = "touché")
const [touched, setTouched] = useState<Partial<Record<keyof AppointmentValues, boolean>>>({});
// A-t-on déjà tenté d'envoyer ? Après un submit raté, on montre TOUT.
const [submitAttempted, setSubmitAttempted] = useState(false);
// Les erreurs sont DÉRIVÉES des valeurs : pas de useState pour elles.
const errors = validateAppointment(values);
// On marque le champ comme "touché" quand il perd le focus.
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setTouched({ ...touched, [e.target.name]: true });
};
// Un champ n'affiche son erreur que s'il a été quitté, OU si on a déjà tenté d'envoyer.
const visibleError = (field: keyof AppointmentValues) =>
(touched[field] || submitAttempted) ? errors[field] : undefined;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// La formule du module précédent : on remplace UNE clé, on recopie le reste.
// Rien d'autre à faire : `errors` étant dérivé, il se recalcule au prochain
// rendu. Un champ déjà `touched` voit donc son message DISPARAÎTRE dès que la
// saisie devient correcte : c'est le guidage, pas le reproche.
setValues({ ...values, [name]: value });
};
Le point le plus important de ce bloc n'est pas touched, c'est
const errors = validateAppointment(values). Les erreurs ne sont pas un
useState : c'est une valeur dérivée, recalculée à chaque rendu. C'est la
règle « ne stocke pas ce que tu peux calculer » du module useState & useEffect — deux états à
synchroniser finissent toujours par se désynchroniser, et ici il est structurellement
impossible que l'erreur affichée corresponde à une ancienne valeur. touched, en
revanche, est bien un état : ça, tu ne peux pas le déduire des valeurs.
Un collègue a écrit ceci pour le champ email, et le trouve « plus réactif » :
<input
name="email"
value={values.email}
onChange={(e) => {
setValues({ ...values, email: e.target.value });
// Validation immédiate, dès le premier caractère
setEmailError(
/.+@.+\..+/.test(e.target.value) ? '' : 'Courriel invalide'
);
}}
/>
{emailError && <span style={{ color: 'red' }}>{emailError}</span>}
Questions : (1) Que voit l'utilisateur, seconde par seconde, quand il tape
patrick@exemple.ca ? (2) Pourquoi dit-on que ce comportement est « hostile » ?
(3) Que changerais-tu, sans rien retirer à la réactivité une fois l'erreur affichée ?
Voir le corrigé
(1) Dès la lettre p, la regex échoue et « Courriel invalide »
apparaît en rouge. Le message reste affiché toute la saisie durant et ne disparaît qu'une fois le
.ca complet, à l'avant-dernier caractère : l'utilisateur passe environ 95 % du temps
face à un message d'erreur, sans avoir rien fait de mal.
(2) Parce que le message n'apporte aucune information utile : à ce stade, l'utilisateur sait qu'il n'a pas fini. Le rouge crée une charge émotionnelle gratuite, puis devient du bruit qu'on apprend à ignorer — ce qui ruine sa valeur pour le moment où il compterait. Sur un lecteur d'écran qui annonce les changements, c'est pire encore : la personne se fait interrompre à chaque frappe. Bonne nouvelle : c'est un défaut que les évaluateurs remarquent immédiatement, donc le corriger te distingue.
(3) Deux changements. Ne valider ce champ qu'à partir de son premier
onBlur — avec un état touched, ou en ne posant d'abord la validation que
sur onBlur ; une fois l'erreur affichée, la revalidation à la frappe redevient utile,
puisqu'elle fait disparaître le message dès que c'est corrigé. Et — on y vient dans la
section suivante — ce message n'est signalé que par de la couleur en ligne, sans
aria-invalid ni role="alert" ni lien avec le champ : il est invisible
pour une partie des utilisateurs.
Le patient arrive sur la page de réservation. Il n'a rien tapé, rien cliqué, rien quitté. Que voit-il ?
const [values, setValues] = useState({ fullName: '', email: '', date: '', service: '' });
const errors = validateAppointment(values);
return (
<>
<TextField label="Nom complet" value={values.fullName} error={errors.fullName} />
<TextField label="Courriel" value={values.email} error={errors.email} />
<TextField label="Date" value={values.date} error={errors.date} />
</>
);
Un formulaire entièrement en rouge, avant le premier caractère. Le calcul
est pourtant juste : errors est dérivé des valeurs — excellente décision, il ne
peut jamais être périmé — mais des valeurs vides sont invalides par définition, donc
les messages existent dès le premier rendu. Le formulaire accueille le patient en lui
reprochant de ne pas avoir rempli ce qu'il vient d'ouvrir.
Ce qui manque n'est pas dans errors : c'est une seconde
information, où l'utilisateur est déjà passé. C'est le rôle de
touched — { email: true } dès qu'un champ perd le focus — doublé de
submitAttempted pour tout montrer après un envoi refusé. errors
dit ce qui ne va pas ; touched dit ce qu'on a le droit de dire. Et on
ne peut pas déduire le second du premier : un champ vide peut l'être parce qu'on ne l'a
jamais visité (se taire) ou parce qu'on l'a rempli puis effacé (parler). Deux questions,
deux états.
Afficher les erreurs de façon ACCESSIBLE
Une erreur qu'on n'a pas perçue n'existe pas — et il y a beaucoup plus de manières de ne pas la percevoir qu'on ne le croit : au lecteur d'écran, en étant daltonien (environ 8 % des hommes), sur un téléphone en plein soleil, ou parce que le message est apparu en haut alors qu'on regardait le bas du formulaire. Un bon message d'erreur est perceptible par plusieurs canaux à la fois. Le patron standard tient en un trio d'attributs, déjà croisés dans HTML sémantique & accessibilité et qui trouvent ici leur usage le plus important :
-
aria-invalid="true"sur le champ : dit à la technologie d'assistance que ce champ est en faute. Le lecteur d'écran annonce « non valide » avec le nom du champ — l'équivalent programmatique de la bordure rouge. -
aria-describedby="id-du-message"sur le champ : rattache le message au champ. Sans ça, le message existe visuellement à côté, mais rien dans le code ne dit qu'ils sont liés ; avec ça, le lecteur d'écran lit le libellé puis le message, dans la foulée. -
role="alert"sur l'élément du message : une région live — « si le contenu change, annonce-le immédiatement, même si le focus est ailleurs ». C'est ce qui fait qu'une erreur apparue après le clic sur « Réserver » est entendue, pas seulement affichée.
type Props = {
id: string;
label: string;
error?: string; // undefined = pas d'erreur sur ce champ
// ...le reste des props d'un <input>
};
export function TextField({ id, label, error, ...inputProps }: Props) {
const errorId = `${id}-error`; // un id stable, dérivé de celui du champ
return (
<div className="field">
{/* htmlFor ↔ id : le clic sur le label donne le focus au champ */}
<label htmlFor={id}>{label}</label>
<input
id={id}
{...inputProps}
/* Signale l'état fautif AUX OUTILS, pas seulement à l'œil */
aria-invalid={error ? true : undefined}
/* Rattache le message au champ. undefined quand il n'y a pas de message :
on ne pointe jamais vers un id qui n'existe pas dans le DOM. */
aria-describedby={error ? errorId : undefined}
/>
{/* role="alert" : annoncé dès son apparition, même sans focus dessus.
Le ⚠ et le texte portent l'information : la couleur n'est qu'un renfort. */}
{error && (
<p id={errorId} role="alert" className="field__error">
<span aria-hidden="true">⚠</span> {error}
</p>
)}
</div>
);
}
Un seul composant réutilisé pour tous les champs : c'est comme ça qu'on garantit que l'accessibilité
est appliquée partout, sans dépendre de la discipline de celui qui écrit le formulaire. Note
le aria-hidden="true" sur le pictogramme : le lecteur d'écran ne dira pas « signe
d'avertissement », il dira le message. L'icône est pour l'œil, role="alert" pour
l'oreille.
Trois règles de placement complètent le trio, et elles comptent autant que lui. Le message va SOUS le champ, jamais au-dessus : au-dessus, il pousse le champ vers le bas en apparaissant, tout saute, et sur mobile le doigt vise soudain autre chose. Sous le champ, la mise en page bouge moins et l'ordre de lecture (libellé → champ → message) suit l'ordre logique.
Ne code jamais l'erreur par la seule couleur. Si le rouge est le seul indice, un utilisateur daltonien voit un champ légèrement plus foncé et ne comprend rien. Il faut un texte explicite, et idéalement un second signal non-coloré : pictogramme, bordure plus épaisse. Le principe a un nom en accessibilité — « ne pas véhiculer l'information par une seule caractéristique sensorielle ».
Sur les gros formulaires, ajoute un résumé en tête. Six messages dispersés sur trois
écrans de défilement, c'est cruel. Un bloc « 3 champs à corriger : Nom, Courriel, Date » avec des
liens qui donnent le focus au champ concerné transforme une chasse au trésor en liste de courses. On y
met role="alert" et on lui donne le focus après un submit raté, pour que la personne au
lecteur d'écran soit ramenée là où l'information est.
Le formulaire de RendezVous n'a que quatre champs : le résumé en tête est facultatif. Le trio
aria-invalid / aria-describedby / role="alert", lui, ne l'est
jamais — il coûte trois attributs. Dans une clinique, ce n'est pas cosmétique : une part
significative des patients qui réservent en ligne sont des personnes âgées ou en situation de
handicap, avec grossissement d'écran, contraste élevé ou lecteur d'écran. Le public d'un site de
santé est structurellement plus concerné par l'accessibilité que la moyenne, et le formulaire de
prise de rendez-vous est la porte d'entrée : s'il est inutilisable, le service l'est aussi.
Voici le champ « date » du formulaire, tel qu'il a été livré en revue :
<label htmlFor="date">Date souhaitée</label>
<input
id="date"
type="date"
value={values.date}
onChange={handleChange}
style={{ borderColor: errors.date ? 'red' : '#ccc' }}
/>
{errors.date && (
<span style={{ color: 'red', fontSize: 12 }}>{errors.date}</span>
)}
Questions : (1) Un utilisateur de lecteur d'écran qui tabule jusqu'à ce champ : qu'entend-il exactement, et que perd-il ? (2) Qu'est-ce qui se passe si l'erreur apparaît après un clic sur « Réserver », alors que le focus est resté sur le bouton ? (3) Nomme les trois attributs manquants et ce que chacun apporte.
Voir le corrigé
(1) Il entend « Date souhaitée, sélecteur de date » — et c'est tout. Le
<label htmlFor> est correct, donc le nom du champ passe. Mais le
<span> rouge n'est associé à rien : il flotte dans le DOM juste après le champ,
et le lecteur d'écran ne le lira que si l'utilisateur explore le document ligne par ligne — ce
qu'on ne fait pas en remplissant un formulaire, où l'on tabule de champ en champ. Il perd donc
deux informations : qu'il y a une erreur, et laquelle. Pour lui, ce formulaire
est simplement cassé.
(2) Rien n'est annoncé. Sans role="alert" (ou
aria-live), aucune technologie d'assistance n'a de raison de signaler l'apparition du
<span>. L'utilisateur reste sur le bouton, dans le silence, à se demander si
son clic a été pris en compte : le pire scénario d'interface, l'absence de retour.
(3) aria-invalid="true" sur l'input → le champ
s'annonce comme fautif. aria-describedby="date-error" sur l'input, avec
l'id correspondant sur le message → le message est lu dans la foulée du libellé.
role="alert" sur le message → il est annoncé dès son apparition, où que soit le
focus. Bonus : un pictogramme pour ne pas dépendre du rouge seul, et des classes CSS au lieu des
styles en ligne, pour que le mode contraste élevé du système puisse reprendre la main.
Écrire de bons messages : dire quoi corriger
Voici la partie que presque personne ne travaille — et c'est précisément pour ça qu'elle te distingue. Un message d'erreur a un seul travail : permettre à l'utilisateur de résoudre le problème, pas le constater. « Champ invalide » constate ; « le numéro doit contenir 10 chiffres, sans espaces » résout. Trois critères suffisent à juger n'importe quel message : dit-il quoi faire et pas seulement ce qui ne va pas ? est-il spécifique à ce champ ou interchangeable ? son ton est-il neutre, factuel, sans blâme ni humour condescendant ? On corrige plus vite quand on ne se sent pas accusé.
| ❌ Mauvais | ✅ Bon | Ce qui a changé |
|---|---|---|
| Champ invalide | Entre un email valide, par exemple nom@exemple.ca | Dit quoi faire, et montre un exemple |
| Erreur | Choisis une date à venir : on ne peut pas réserver dans le passé | Nomme la règle et sa raison |
| Format incorrect | Le téléphone doit contenir 10 chiffres, ex. 514-555-0199 | Précise le format attendu au lieu de le sous-entendre |
| Vous avez oublié de remplir ce champ | Le nom est requis pour confirmer le rendez-vous | Retire le blâme, ajoute le pourquoi |
| Mot de passe non conforme | Ajoute au moins un chiffre — il en manque un | Cible ce qui manque, pas la conformité en bloc |
| Une erreur est survenue | Impossible de joindre le serveur. Vérifie ta connexion et réessaie | Distingue la panne réseau du refus métier |
Deux subtilités. Le message d'un champ obligatoire gagne à dire pourquoi la donnée est
demandée quand ce n'est pas évident : « Le email est requis » est correct, « Entre un email :
c'est là qu'on envoie ta confirmation » est meilleur, parce qu'il transforme une contrainte en service
rendu. Et évite absolument le vocabulaire de ton code : « le champ service_id est
null » n'a de sens que pour toi — l'utilisateur ne sait pas que ton formulaire a des
champs, encore moins qu'ils ont des identifiants.
En test technique, la qualité des messages d'erreur est un des rares signaux qui distinguent un candidat « qui sait faire marcher React » d'un candidat « qui sait construire un produit ». Deux candidats rendent un formulaire qui fonctionne ; celui dont les messages disent quoi corriger a montré qu'il pense à quelqu'un d'autre que lui. C'est presque gratuit et très visible à la lecture.
L'état d'envoi complet : idle, submitting, success, error
Un envoi n'est pas un instant, c'est une durée, avec plusieurs issues. Les gestes de
base sont acquis depuis Formulaires 1 — contrôlé ou non (preventDefault, la garde anti
double-envoi, le finally) : ils réapparaissent ci-dessous sans être réexpliqués. Ce que ce
module ajoute, c'est un modèle plus fin que le booléen isSubmitting : une petite
machine à états, où le formulaire est à tout moment dans exactement un des
quatre états suivants.
| État | Ce que ça veut dire | Ce que l'interface doit faire |
|---|---|---|
idle | Au repos, rien n'est parti | Bouton actif, libellé normal |
submitting | La requête est en vol | Bouton désactivé + « Envoi… », champs figés |
success | Le serveur a confirmé | Message de succès, formulaire réinitialisé |
error | Réseau coupé ou refus serveur | Message d'erreur global, bouton réactivé |
- idlerien n'est parti ; bouton actif, libellé normal
- submittingla requête est en vol ; bouton désactivé, « Envoi… » c'est ce disabled qui bloque le double clic
-
successsi
res.ok— le serveur a confirmé ; message de succès et c'est ICI, et nulle part ailleurs, qu'on vide le formulaire - errorsinon — réseau coupé ou refus serveur ; message global, bouton réactivé la saisie est CONSERVÉE
success et error sont les deux sorties de
submitting — jamais les deux à la fois, et c'est tout l'intérêt d'une variable
à quatre valeurs plutôt que de trois booléens, qui autoriseraient huit combinaisons dont
plusieurs absurdes. La glissière couvre exactement les étapes qu'elle concerne :
sa pointe désigne submitting, parce que depuis error un nouveau
clic y renvoie. C'est pourquoi la remise à zéro appartient à la branche
success : la vider dans error, ce serait effacer la saisie de
quelqu'un à qui l'on vient de demander de réessayer.
Pourquoi une seule variable à quatre valeurs plutôt que trois booléens (isLoading,
isSuccess, isError) ? Parce que trois booléens permettent huit combinaisons,
dont plusieurs sont absurdes : « en chargement ET en succès ET en erreur » est représentable, donc un
jour ça arrivera. Avec une variable unique, les états impossibles sont littéralement
impossibles à écrire — le principe porte un nom juste, « rendre les états invalides
irreprésentables », et un type 'idle' | 'submitting' | 'success' | 'error' te le donne
gratuitement, auto-complétion comprise.
type Status = 'idle' | 'submitting' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
const [formError, setFormError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); // on garde la main : pas de rechargement de page
setSubmitAttempted(true); // à partir d'ici, on montre TOUTES les errors
// On revalide ICI, au moment du geste — sans réutiliser la variable `errors`
// dérivée plus haut, pour ne pas la masquer avec un nom identique.
const submitErrors = validateAppointment(values);
if (Object.keys(submitErrors).length > 0) return; // {} vide = valide, sinon on s'arrête
// Garde-fou anti double-envoi : si une requête est déjà partie, on ignore.
if (status === 'submitting') return;
setStatus('submitting');
setFormError(null);
try {
const res = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
// LE fetch honnête : une réponse 400 ou 500 ne lève PAS d'exception.
// Sans ce test, une erreur serveur passerait pour un succès.
if (!res.ok) throw new Error(`Le serveur a répondu ${res.status}`);
setStatus('success');
setValues({ fullName: '', email: '', date: '', service: '' }); // on repart à neuf
setTouched({});
setSubmitAttempted(false);
} catch {
setStatus('error');
setFormError("Impossible d'enregistrer le rendez-vous. Vérifie ta connexion et réessaie.");
}
}
Le if (!res.ok) throw est le point le plus souvent raté — le « fetch honnête » du module
HTTP & fetch : fetch ne rejette sa promesse que si la requête n'est jamais partie
(réseau coupé, DNS mort). Un 400 ou un 500 arrive tranquillement dans le
try, comme une réponse parfaitement normale.
Côté rendu, la machine à états pilote le bouton. Trois points à ne pas manquer : le
disabled pendant l'envoi (la protection principale contre le double clic), le libellé qui
change, et le message de succès en région live pour qu'il soit annoncé.
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Envoi en cours…' : 'Réserver'}
</button>
{/* Succès et échec global : deux régions live, jamais les deux à la fois */}
{status === 'success' && (
<p role="status" className="succes">
Rendez-vous confirmé. Un email de confirmation vient de partir.
</p>
)}
{status === 'error' && formError && (
<p role="alert" className="erreur-globale">{formError}</p>
)}
role="status" pour le succès, role="alert" pour l'échec : deux régions live,
mais alert est « assertif » (il interrompt la lecture) et status « poli »
(il attend une pause). Un succès n'a pas à interrompre ; une erreur, si.
Une route serveur qui écrit db.appointments.create({ data }) avec le corps de la
requête tel quel accepte tous les champs que le client a bien voulu envoyer — y
compris ceux que ton formulaire n'affiche pas. Un POST fabriqué à la main peut donc
glisser status: 'confirmed', price: 0 ou isAdmin: true dans
ta base. On appelle ça une mass assignment : le serveur fait confiance à
la forme de ce qu'il reçoit, pas seulement à son contenu.
La parade tient en une phrase : le serveur reconstruit toujours explicitement l'objet à insérer, champ par champ, au lieu d'étaler ce qui arrive. Un schéma qui strippe les clés inconnues (Zod, plus bas) fait ce travail pour toi et te rend un objet propre, ne contenant que les champs attendus. Le piège est vicieux parce qu'il est invisible : le formulaire marche, les tests passent, la démo est impeccable — la faille n'est visible que du côté de ce que tu n'as pas écrit.
Voici l'envoi tel qu'écrit par un candidat, avec la route serveur correspondante :
// --- CÔTÉ CLIENT ---
async function handleSubmit(e) {
e.preventDefault();
const errors = validate(values);
if (Object.keys(errors).length) { setErrors(errors); return; }
await fetch('/api/appointments', {
method: 'POST',
body: JSON.stringify(values),
});
alert('Rendez-vous confirmé !');
}
// --- CÔTÉ SERVEUR ---
export async function POST(request) {
const data = await request.json();
await db.appointments.create({ data }); // insertion directe
return Response.json({ ok: true });
}
Questions : (1) Quelle faille contient ce code ? (2) Cite deux données aberrantes qu'on pourrait insérer en base sans jamais ouvrir la page. (3) Il y a aussi un bug qui n'a rien à voir avec la sécurité : lequel ?
Voir le corrigé
(1) Le serveur ne valide rien : il désérialise le corps de la
requête et l'insère tel quel. Toute la validation vit dans le navigateur, donc dans un code que
l'attaquant contrôle entièrement — un POST direct vers
/api/appointments contourne 100 % des règles. Et le { data } passé
directement à la base est la seconde faille, celle de l'encadré ci-dessus : la
mass assignment.
(2) Au choix : un rendez-vous en 1998 (la règle « date future » n'existait que
côté client) ; un nom de 200 000 caractères qui fait exploser le tableau de bord ; un email qui
n'en est pas un, donc une confirmation qui échoue silencieusement ; un service qui ne
correspond à rien, laissant une ligne orpheline ; ou dix mille insertions en boucle, puisque rien
ne limite le débit.
(3) Il manque le contrôle de res.ok : le
await fetch(...) réussit même sur un 500, donc « Rendez-vous
confirmé ! » s'affiche quoi qu'il arrive et l'utilisateur se présentera à la clinique pour un
rendez-vous qui n'existe pas. Accessoirement : pas d'état submitting, donc rien
n'empêche le double clic, et l'alert natif est un choix d'interface qu'un évaluateur
relèvera.
Le serveur répond 500 : la base était injoignable, rien n'a été
enregistré. Dans quel état finit le formulaire, et qu'est-ce que le patient vient de
perdre ?
setStatus('submitting');
try {
await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
setStatus('success');
setValues(EMPTY); // on repart à neuf
} catch {
setStatus('error');
}
status finit à 'success'. Le patient lit « Rendez-vous confirmé »,
voit le formulaire se vider, ferme l'onglet — et découvrira le mardi suivant, à l'accueil de
la clinique, qu'il n'avait pas de rendez-vous. Le catch n'a jamais été
atteint : fetch ne rejette sa promesse que si la requête n'a pas
abouti (réseau coupé, DNS mort). Un 500 est une réponse, donc un succès de
son point de vue, et il traverse le try sans un bruit. Il manque
if (!res.ok) throw new Error(...).
Repère surtout le deuxième dégât, celui qu'on n'anticipe jamais : le
setValues(EMPTY) a détruit la saisie. Non seulement l'envoi a
échoué, mais l'utilisateur ne peut même pas réessayer sans tout retaper. C'est pour ça que
la remise à zéro appartient strictement à la branche success de la machine à
états, et jamais à un vague « après l'envoi ».
Typer une config : l'union discriminée
Le formulaire piloté par une configuration a été vu dans Formulaires 1 — contrôlé ou non. Ajoute-lui
un bloc rules par champ (required, minLength,
future) et la config décrit non plus seulement « quels champs », mais « à quelles
conditions ils sont valides ». Reste une question : comment typer ça ? Un champ
select a des options qu'un champ texte n'a pas, et une règle
future n'a aucun sens ailleurs que sur une date. Le mauvais réflexe serait un seul type
avec tout en facultatif — on perdrait toute vérification. Le bon outil s'appelle une
union discriminée, et c'est la vraie leçon durable de ce patron.
Le principe : un type par variante, chacun contenant un champ commun dont la valeur est
littérale et différente à chaque fois (ici type, valant 'text',
'email', 'date' ou 'select'). Ce champ est le
discriminant : l'étiquette qui dit de quelle variante il s'agit. On fait ensuite
l'union des variantes avec des |, et TypeScript devient capable de rétrécir le
type tout seul : dans une branche if (field.type === 'select'), il sait que
field.options existe, et il refuse d'y accéder ailleurs. C'est le mécanisme décortiqué
dans Lire les types TypeScript — ici il travaille pour toi.
// Les règles possibles. Toutes facultatives : un champ n'en porte que certaines.
type Rules = {
required?: boolean;
minLength?: number;
future?: boolean; // n'a de sens que pour une date
};
type Base = { name: string; label: string; hint?: string; rules?: Rules };
// UNION DISCRIMINÉE : `type` est le discriminant. Chaque variante a sa forme.
export type FieldConfig =
| (Base & { type: 'text' })
| (Base & { type: 'email' })
| (Base & { type: 'date' })
| (Base & { type: 'select'; options: string[] }); // seul le select a options
export type FormConfig = { title: string; fields: FieldConfig[] };
Le gain est immédiat : écrire { type: 'text', options: [...] } devient une erreur de
compilation, et oublier options sur un select aussi. Le type encode
la règle « seul un select a des options » — plus besoin de la documenter, elle est vérifiée.
Sois lucide sur le compromis, un bon évaluateur te posera la question. Un formulaire piloté par
config est excellent quand tu as beaucoup de formulaires très semblables (CRUD d'admin,
back-office, questionnaires configurables). Il est franchement mauvais quand tu as un
formulaire avec des règles particulières : tu tords la config pour exprimer des cas spéciaux et tu
finis avec un langage de configuration maison, moins puissant que le JavaScript que tu aurais écrit
directement. Un validateur générique piloté par ces règles rendrait d'ailleurs exactement le même
dictionnaire { field: message } que le validateur écrit à la main plus haut : une
ré-implémentation, pas un gain. La bonne réponse en entrevue n'est jamais « c'est mieux », c'est
« ça dépend du nombre de formulaires et de l'homogénéité des règles ».
Les bibliothèques, honnêtement
Tout ce qu'on vient d'écrire à la main existe en version outillée. Il faut connaître ces bibliothèques — on te demandera si tu les as utilisées — mais surtout savoir pourquoi elles existent, sinon tu ne sauras jamais quand t'en passer.
react-hook-form
Le choix par défaut de l'écosystème React. Son idée centrale est astucieuse : plutôt que de mettre
chaque champ en état contrôlé (un re-render du formulaire à chaque frappe), il travaille en
mode non contrôlé — il enregistre les champs via des refs et lit leurs
valeurs dans le DOM au moment voulu. Taper ne re-render donc presque rien, et sur trente champs la
différence de fluidité est perceptible. Il apporte aussi, tout prêts, touched,
isSubmitting, dirty (« modifié depuis le chargement »), les tableaux de
champs dynamiques et le branchement d'un schéma de validation.
Zod (et son petit cousin Valibot)
Zod change la nature de l'exercice : au lieu d'écrire une fonction pleine de if, tu
déclares un schéma, une description de la forme attendue des données. Le schéma
devient la source de vérité unique et — le point vraiment fort — TypeScript peut en dériver
le type. Tu n'as plus deux choses à garder synchronisées, tu en as une seule.
import { z } from 'zod';
export const appointmentSchema = z.object({
fullName: z.string().trim().min(2, 'Le nom doit contenir au moins 2 caractères.'),
email: z.string().email('Ce email semble incomplet. Exemple : nom@exemple.ca'),
date: z.string().refine(
(d) => new Date(d + 'T00:00') > new Date(),
'Choisis une date à venir.'
),
service: z.enum(['Physiothérapie', 'Ostéopathie', 'Massothérapie']),
});
// Le TYPE est DÉRIVÉ du schéma : une seule source de vérité, jamais de dérive.
export type AppointmentValues = z.infer<typeof appointmentSchema>;
// Côté client comme côté serveur, exactement le même appel :
const result = appointmentSchema.safeParse(data);
if (!result.success) {
// result.error contient les messages, champ par champ
}
safeParse renvoie { success, data | error } au lieu de lever une exception —
exactement la distinction Result-based vs throwing évoquée plus haut. Zod offre aussi
parse, qui lance : un seul schéma, deux saveurs, l'interface prend la douce et l'écriture
en base la brutale.
Ce n'est pas une nouveauté web pour toi : ton app mobile utilise déjà des schémas Zod, notamment
pour valider ce qui est persisté dans les réglages. Et tu as même vécu son piège classique — un
champ absent du schéma se fait silencieusement retirer à la sérialisation, puis
écraser au démarrage suivant. Ce comportement s'appelle le stripping : par défaut, Zod ne
garde que ce que le schéma déclare. C'est une fonctionnalité (elle te protège des champs
inattendus, exactement la mass assignment évoquée plus haut) qui devient un piège quand on
oublie de déclarer un champ. Sur le web, la même mécanique te sert de rempart côté serveur : un
safeParse sur le corps de la requête te rend un objet propre, contenant uniquement les
champs attendus, prêt à insérer. Même bibliothèque, même modèle mental, deux plateformes — c'est
précisément le genre de transfert qui rend ton expérience mobile crédible en entrevue web.
Formik
Formik a longtemps été la réponse aux formulaires React : contrôlé, plus verbeux, son développement est nettement moins actif. Tu dois savoir le lire dans du code existant, tu n'as pas de raison de démarrer un nouveau projet avec.
Alors, à la main ou en bibliothèque ?
Le critère honnête : trois ou quatre champs avec des règles simples, écris-le à la main — la bibliothèque coûte plus cher qu'elle ne rapporte (poids, API à apprendre, indirection pour le prochain lecteur). Un formulaire long, des champs conditionnels, des tableaux répétables, des règles croisées, ou dix formulaires dans la même application : react-hook-form + Zod, sans hésiter.
Mais voici ce qui compte pour toi : en entrevue et en test technique, on te demandera presque
toujours de le faire à la main. Pas par nostalgie — c'est le seul moyen de vérifier que tu
comprends ce que la bibliothèque fait à ta place. Qui ne sait qu'appeler useForm() est
bloqué dès que le comportement par défaut ne convient pas ; qui a écrit un validateur pur, un état
touched et une machine à quatre états comprend exactement ce que
react-hook-form lui offre et peut argumenter son choix. C'est cette personne qu'on embauche.
Le corps reçu contient price: 0, un champ que ton formulaire n'affiche nulle
part. Le schéma le rejette-t-il ? Et que contient la ligne finalement insérée en
base ?
export async function POST(request) {
const body = await request.json();
const result = appointmentSchema.safeParse(body);
if (!result.success) {
return Response.json({ error: 'Données invalides' }, { status: 400 });
}
await db.appointments.create({ data: { ...body, ...result.data } });
return Response.json({ ok: true });
}
Non, le schéma ne rejette rien : par défaut, Zod strippe les clés
inconnues au lieu de les refuser. safeParse réussit donc, et
result.data est un objet propre, contenant exactement les champs déclarés —
price a bel et bien disparu. C'est le comportement que tu as déjà rencontré
côté mobile, en négatif : un champ oublié dans le schéma se fait silencieusement retirer des
réglages persistés.
Et pourtant la ligne insérée contient price: 0. Le ...body
réintroduit littéralement tout ce que le schéma venait d'écarter : le nettoyage a eu lieu,
puis on a recollé les déchets par-dessus. C'est une mass assignment complète, avec
le rempart en place et court-circuité par un étalement de trois caractères — et rien dans
les tests du formulaire ne la révélera. Correctif : data: result.data, et rien
d'autre. La règle générale vaut bien au-delà de Zod : après validation, le seul
objet autorisé à continuer est celui que le validateur t'a rendu, jamais celui qui
est arrivé.
« Où valides-tu tes formulaires, et pourquoi pas seulement côté client ? »
À trois endroits, avec trois rôles différents. Les attributs HTML natifs
(required, type="email") pour un filet gratuit qui marche même sans
JavaScript et qui améliore l'ergonomie mobile. Une validation JavaScript côté client pour le
confort : feedback immédiat, messages soignés, pas d'aller-retour réseau inutile. Et surtout la
validation serveur, qui est la seule qui compte vraiment — parce que tout ce qui tourne dans le
navigateur est du code que l'utilisateur contrôle. On peut supprimer un required
dans les outils de développement, ou poster directement sur l'API avec curl sans
jamais ouvrir la page. Ma formule : valider côté client, c'est de l'ergonomie ; valider
côté serveur, c'est de la sécurité. En pratique j'écris mes règles dans une fonction
pure — ou un schéma Zod — et je l'exécute des deux côtés, ce qui me garantit qu'il n'y a aucune
divergence entre les deux couches.
« Comment rends-tu un message d'erreur accessible ? »
Avec trois attributs. aria-invalid="true" sur le champ, pour que le lecteur d'écran
annonce qu'il est en faute. aria-describedby pointant vers l'id du
message, pour que le message soit lu dans la foulée du libellé quand le focus arrive sur le
champ — sans ça, le message existe visuellement mais n'est rattaché à rien. Et
role="alert" sur le message lui-même, qui en fait une région live : il est annoncé
dès son apparition, même si le focus est ailleurs, ce qui est le cas typique après un clic sur
« Envoyer ». J'ajoute deux règles de placement : le message va sous le champ, pour ne pas
décaler la mise en page au-dessus de ce que l'utilisateur regarde ; et l'erreur n'est jamais
signalée par la seule couleur — il faut du texte, et si possible un pictogramme, sinon un
utilisateur daltonien ne perçoit rien. Sur un formulaire long, j'ajoute un résumé des erreurs en
tête, avec le focus déplacé dessus après un envoi refusé.
« react-hook-form ou à la main ? »
Ça dépend de la taille du formulaire. Pour trois ou quatre champs avec des règles simples, je le
fais à la main : un objet values, un validateur pur, un objet touched,
et un statut d'envoi. C'est une centaine de lignes très lisibles, et j'évite une dépendance.
Pour un formulaire long, ou dès qu'il y a des champs conditionnels et des tableaux répétables,
je prends react-hook-form avec un schéma Zod : react-hook-form travaille en mode non contrôlé,
donc taper ne re-render pas tout le formulaire, et Zod me donne le schéma et le type TypeScript
dérivé, réutilisables côté serveur. Cela dit, je tiens à savoir le faire à la main, parce que
c'est ce qui me permet de comprendre ce que la bibliothèque fait pour moi et de déboguer quand
son comportement par défaut ne correspond pas au besoin.
La validation a trois lieux : le navigateur (attributs natifs, gratuits, difficiles
à styler — d'où le noValidate fréquent, en gardant les attributs pour leur sémantique),
le client (le confort) et le serveur (la sécurité, la seule qui
compte). Le patron central est le validateur pur :
validate(values) → { field: message }, objet vide = valide — testable,
réutilisable des deux côtés, lisible. Le quand compte autant que la règle : on valide au
blur, jamais dès le premier caractère, puis en direct une fois qu'un champ est déjà signalé ; c'est
le rôle de l'état touched. On affiche avec le trio aria-invalid +
aria-describedby + role="alert", sous le champ, jamais par la couleur
seule. Les messages disent quoi corriger, pas « champ invalide ». L'envoi est une
machine à quatre états — idle, submitting, success,
error — avec bouton désactivé pendant l'envoi et test de res.ok. Et les
bibliothèques (react-hook-form, Zod) valent le coup dès que ça grossit, mais on te demandera de
savoir le faire à la main.
Et ailleurs : deux idées de ce module dépassent largement les formulaires web. La première : « ne fais jamais confiance à une entrée qui vient d'ailleurs ». C'est le principe qui fonde la validation d'une charge utile de synchronisation, les règles d'accès d'une base de données ou l'assainissement d'un fichier importé. Toute frontière entre « chez moi » et « chez quelqu'un d'autre » est un point de validation obligatoire — et « qui contrôle la machine où ce code s'exécute ? » est la question de sécurité la plus rentable que tu puisses te poser.
La seconde : séparer la décision de son affichage. Une fonction pure décide (voici les erreurs), une couche d'interface présente (voici comment et quand on les montre). Cette séparation te suivra partout — moteurs de règles métier, calculs d'analytique, machines à états — et c'est le raisonnement qui fait vivre les calculs déterministes de ton app mobile en dehors des composants qui les affichent.
noValidate- coupe l'interface native (bulles, blocage), pas les attributs :
requiredreste annoncé,typechange encore le clavier - Validateur pur
- rend un objet
{ champ: message }; vide = valide, testé parObject.keys(errors).length === 0 - L'objet vide est truthy
if (errors)est toujours vrai. Sont falsy :0,'',null,undefined,NaN,false— jamais{}ni[]errors/touchederrorsse dérive des valeurs (aucunuseState) ;touchedest un vrai état, indéduisible- Le trio d'accessibilité
aria-invalidsur le champ,aria-describedbyvers l'iddu message,role="alert"sur le messagealertoustatusrole="alert"est assertif (il interrompt) — pour l'échec ;role="status"est poli — pour le succès- Le
fetchhonnête if (!res.ok) throw: un400ou un500ne rejette pas la promesse- Le reset
- appartient à la branche
successseule — danserror, il effacerait la saisie qu'on demande de renvoyer - Où va le message
- sous le champ (au-dessus, il pousse la mise en page), et jamais signalé par la seule couleur