Module 8 · React côté web

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.

LieuRôle principalContournable ?Coût pour toi
Navigateur (attributs HTML)Filet de sécurité gratuit, fonctionne sans JSOui, trivialementQuasi nul
Client (ton JS / React)Confort, feedback immédiat, messages soignésOui, trivialementMoyen
ServeurSécurité et intégrité des donnéesNonMoyen — mais non négociable
Les trois filtres qu'une donnée traverse, et le seul qu'on ne peut pas retirer
  1. navigateurLes attributs HTMLrequired, type="email", min, patternretiré en deux secondes dans les outils de dév
  2. 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
  3. serveurLe contrôle serveurles mêmes règles, sur une machine que l'utilisateur ne contrôle pasla seule barrière réelle
Les deux premiers filtres sont du confort : ils font gagner du temps à l'utilisateur honnête, et ils tombent devant un 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.
📅 Dans RendezVous

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.

un formulaire RendezVous, en HTML pur
<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 :

  1. 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.
  2. 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.
  3. 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.
  4. 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.
🧭 Désactiver l'affichage, garder la sémantique

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é.

🧠 Quiz éclair

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.
📖 La formule

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.

lib/validation/appointment.ts
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.

🧭 Pourquoi une regex de email aussi permissive ?

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.

🔗 Pont — tes validateurs mobiles, en deux saveurs

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.

🧠 Quiz éclair

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.

Le patient tape « patrick@exemple » et oublie le .ca — quand le message apparaît
à la frappe « Courriel invalide » — dès la 1re lettre, et sans discontinuer
au blur silence le message
au submit silence !
il tape « patrick@ » …« exemple » il quitte le champ, relit, puis clique sur Réserver
Une colonne vaut une unité de saisie. Les trois pistes portent la même règle et la même faute : seul l'instant du déclenchement change. À la frappe, le message est là pendant toute la saisie — il a crié avant la faute, donc quand la faute arrive il ne veut plus rien dire. Au submit, c'est la marque d'une seule colonne tout à droite : rien, rien, rien, puis le refus, après que la personne a cru avoir fini. Au blur, il tombe à l'instant précis où elle vient de quitter le champ et peut encore corriger sans remonter dix champs : c'est le seul des trois qui parle au bon moment.
💡 La règle d'ergonomie, en une ligne

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.

components/AppointmentForm.tsx
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.

✍️ Exercice de lecture

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.

🧠 Quiz éclair

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.
components/TextField.tsx
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.

📅 Dans RendezVous

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.

✍️ Exercice de lecture

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✅ BonCe 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.

🧭 Un critère de notation, pas un détail cosmétique

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.

ÉtatCe que ça veut direCe que l'interface doit faire
idleAu repos, rien n'est partiBouton actif, libellé normal
submittingLa requête est en volBouton désactivé + « Envoi… », champs figés
successLe serveur a confirméMessage de succès, formulaire réinitialisé
errorRéseau coupé ou refus serveurMessage d'erreur global, bouton réactivé
Les deux sorties de submitting — et ce que chacune fait de la saisie
  1. idlerien n'est parti ; bouton actif, libellé normal
  1. submittingla requête est en vol ; bouton désactivé, « Envoi… » c'est ce disabled qui bloque le double clic
  2. successsi res.ok — le serveur a confirmé ; message de succès et c'est ICI, et nulle part ailleurs, qu'on vide le formulaire
  3. errorsinon — réseau coupé ou refus serveur ; message global, bouton réactivé la saisie est CONSERVÉE
réessayer → submitting
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.

components/AppointmentForm.tsx
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.

⚠️ Piège fréquent — la mass assignment

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.

✍️ Exercice de lecture

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.

🧠 Quiz éclair

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.

types/form.ts
// 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.

🧭 Le prix de la généricité

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.

lib/validation/appointment.schema.ts
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.

🔗 Pont — Zod, tu l'utilises déjà

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.

🧠 Quiz éclair

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é.

🎤 En entrevue

« 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.

À retenir

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.

🗂️ L'aide-mémoire
noValidate
coupe l'interface native (bulles, blocage), pas les attributs : required reste annoncé, type change encore le clavier
Validateur pur
rend un objet { champ: message } ; vide = valide, testé par Object.keys(errors).length === 0
L'objet vide est truthy
if (errors) est toujours vrai. Sont falsy : 0, '', null, undefined, NaN, false — jamais {} ni []
errors / touched
errors se dérive des valeurs (aucun useState) ; touched est un vrai état, indéduisible
Le trio d'accessibilité
aria-invalid sur le champ, aria-describedby vers l'id du message, role="alert" sur le message
alert ou status
role="alert" est assertif (il interrompt) — pour l'échec ; role="status" est poli — pour le succès
Le fetch honnête
if (!res.ok) throw : un 400 ou un 500 ne rejette pas la promesse
Le reset
appartient à la branche success seule — dans error, 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