Module 7 · React côté web

Formulaires 1 — contrôlé ou non

Si tu ne devais maîtriser qu'un seul sujet avant ton prochain test technique, ce serait celui-ci. Le formulaire est l'exercice universel des entrevues frontend : il tient dans une heure, il touche à l'état, aux événements, à l'accessibilité et au réseau. Ce module pose les fondations : ce que le navigateur sait déjà faire tout seul, les deux manières de brancher React dessus (contrôlé et non contrôlé), et le patron qui gère vingt champs avec un seul gestionnaire. La validation a son propre module, juste après.

💡 L'idée en une phrase

Un formulaire, c'est une question de propriété : qui détient la valeur que l'utilisateur est en train de taper ? Si c'est React (dans un useState), le champ est dit contrôlé. Si c'est le DOM du navigateur (le champ garde sa valeur tout seul, comme il le fait depuis 1995), le champ est non contrôlé. Tout le reste — les pièges, les questions d'entrevue, les bugs — découle de cette seule question.

Le formulaire natif, avant même de parler de React

Avant de plonger dans React, il faut comprendre ce que le navigateur fait déjà : React ne remplace pas ce mécanisme, il se branche dessus. Beaucoup de candidats perdent des points ici, non parce qu'ils connaissent mal React, mais parce qu'ils n'ont jamais regardé un formulaire HTML tout nu.

Le formulaire est l'une des plus vieilles briques du web : bien avant JavaScript, le HTML savait déjà envoyer des données à un serveur. Le mécanisme n'a pas changé — tu enveloppes des champs dans un <form>, tu lui donnes une destination (action) et une méthode HTTP (method), et le navigateur prend le relais à la soumission.

<!-- Un formulaire 100 % natif : aucune ligne de JavaScript. -->
<form action="/rendez-vous" method="post">
  <label for="nom">Nom complet</label>
  <input id="nom" name="fullName" type="text" required />

  <label for="email">Courriel</label>
  <input id="email" name="email" type="email" required />

  <button type="submit">Réserver</button>
</form>

Ce formulaire fonctionne. Sans React, sans fetch, sans une seule ligne de JS. C'est le point de départ qu'il faut avoir en tête.

Ce qui se passe exactement à la soumission

Quand l'utilisateur clique sur « Réserver » (ou appuie sur Entrée dans un champ), le navigateur déroule une séquence très précise :

  1. Il parcourt tous les champs et retient ceux qui ont un attribut name. Les autres sont purement et simplement ignorés.
  2. Il construit une paire name / value pour chacun et les encode (par défaut en application/x-www-form-urlencoded, qui ressemble à fullName=Patrick&email=p%40exemple.ca).
  3. Il envoie une vraie requête HTTP vers l'URL de action — en query string avec method="get", dans le corps avec method="post" (module HTTP & fetch).
  4. Il remplace la page entière par la réponse du serveur.

Cette dernière étape est la clé. Le formulaire natif provoque une navigation complète : parfait pour le web « classique » de documents, catastrophique dans une single-page application, où recharger la page détruit tout l'état React en mémoire. Ton formulaire multi-étapes avec sa barre de progression et ses données saisies ? Effacé.

Pourquoi on intercepte : preventDefault

D'où le geste fondateur du formulaire en React : on intercepte l'événement de soumission et on annule le comportement par défaut du navigateur.

function handleSubmit(event) {
  event.preventDefault(); // ← on annule la navigation + le rechargement de page
  // À partir d'ici, c'est NOUS qui décidons quoi faire des données.
}

preventDefault() ne « bloque » pas l'événement : il dit « n'exécute pas ton action par défaut ». Le même mécanisme empêche un lien de naviguer.

🔗 Pont — l'action par défaut, revisitée

On a rencontré preventDefault dans le module Le navigateur, en parlant du cycle de vie d'un événement : capture, cible, remontée (bubbling), puis action par défaut du navigateur. Un formulaire est simplement le cas le plus spectaculaire de cette dernière phase : l'action par défaut de l'événement submit, c'est envoyer une requête HTTP et remplacer la page. Rien de magique, rien de spécifique à React : tu utilises exactement la même API que pour empêcher un <a> de suivre son lien. Comprendre ça t'évite de croire que preventDefault est « un truc de formulaire React ».

🧭 L'attribut name n'est pas mort

Quand on passe au tout-React, on est tenté d'oublier name : après tout, on lit les valeurs depuis useState. Erreur. Il reste indispensable pour les boutons radio (c'est lui qui les regroupe) et pour l'autocomplétion. Et il revient en pleine gloire avec les Server Actions de Next.js (module Route Handlers & Server Actions) : on écrit <form action={maFonctionServeur}> et c'est le framework qui collecte les champs par leur name, exactement comme le navigateur de 1995. Prends l'habitude de toujours nommer tes champs.

Ce que le navigateur t'offre gratuitement

Deuxième chose qu'on sous-estime toujours : la richesse des champs natifs. En React Native, tu as TextInput et tu construis le reste. Sur le web, le navigateur te donne une boîte à outils déjà fournie, où chaque outil arrive avec des comportements gratuits que tu paierais cher à réimplémenter.

ÉlémentÀ quoi ça sert
<input type="text">Texte libre sur une ligne. Le défaut.
<input type="email">Clavier avec @ sur mobile + validation de forme basique.
<input type="tel">Pavé numérique de téléphone sur mobile. Aucune validation (les formats varient trop d'un pays à l'autre).
<input type="number">Valeur numérique, avec petites flèches et min/max/step.
<input type="date">Sélecteur de date natif — celui du système, déjà traduit et accessible.
<input type="checkbox">Booléen. Sa valeur se lit dans checked, pas dans value.
<input type="radio">Choix unique dans un groupe. C'est le name partagé qui forme le groupe.
<select> + <option>Liste déroulante. Sur mobile, le navigateur affiche une roulette native.
<textarea>Texte long, multi-lignes, redimensionnable.

Dans presque chaque ligne, il y a un cadeau. Le clavier adapté sur mobile (type="email" fait apparaître la touche @, type="tel" ouvre le pavé numérique) est probablement le meilleur rapport qualité/effort de tout le développement web. La validation de base (required, min, maxlength, pattern) donne un premier filet sans une ligne de JS — on la creusera dans le module Formulaires 2 — validation & erreurs. Et l'autocomplétion (autocomplete="email", "tel", "name") laisse le navigateur et le gestionnaire de mots de passe remplir le formulaire d'un tap : gain de conversion énorme, coût nul.

📅 Dans RendezVous

Le formulaire de réservation de la clinique utilise cinq types de champs différents, et chacun est choisi pour ce que le navigateur offre en plus :

<label for="nom">Nom complet</label>
<input id="nom" name="fullName" type="text"
       autocomplete="name" required />

<label for="email">Courriel</label>
<input id="email" name="email" type="email"
       autocomplete="email" required />

<label for="tel">Téléphone</label>
<input id="tel" name="phone" type="tel" autocomplete="tel" />

<label for="service">Service</label>
<select id="service" name="service">
  <option value="physio">Physiothérapie</option>
  <option value="massage">Massothérapie</option>
</select>

<label for="notes">Précisions (facultatif)</label>
<textarea id="notes" name="notes" rows="4"></textarea>

Chaque champ a un id relié à son <label for> : c'est la règle d'accessibilité posée dans le module HTML sémantique & accessibilité, et elle a un bénéfice très concret sur mobile — taper sur l'étiquette met le focus dans le champ, ce qui agrandit énormément la zone cliquable.

🎤 En entrevue

« Tu dois faire un champ de saisie de date. Tu utilises quoi ? »

« Je commence par <input type="date">. C'est gratuit, c'est accessible au clavier, c'est déjà traduit dans la langue du système, et sur mobile ça ouvre le sélecteur natif que l'utilisateur connaît déjà. »

« Je passe à une librairie seulement si j'ai un besoin que le natif ne couvre pas : une sélection de plage, des dates désactivées, l'affichage des disponibilités dans le calendrier. Et à ce moment-là c'est un choix assumé, avec son coût — du JavaScript en plus à télécharger, et une accessibilité que je dois vérifier moi-même au lieu de l'avoir gratuitement. »

Le composant contrôlé : React devient la source de vérité

Passons à React. Un champ contrôlé est un champ dont la valeur affichée est entièrement dictée par l'état React. On le reconnaît à deux attributs qui vont toujours ensemble : value et onChange.

app/rendez-vous/NameField.tsx
import { useState } from 'react';

export function NameField() {
  // L'état React détient la valeur du champ. C'est LUI la vérité.
  const [fullName, setFullName] = useState('');

  return (
    <>
      <label htmlFor="nom">Nom complet</label>
      <input
        id="nom"
        name="fullName"
        value={fullName}                                  // ① ce qui s'affiche vient de l'état
        onChange={(e) => setFullName(e.target.value)}      // ② toute frappe met l'état à jour
      />
      {/* Puisque l'état est la vérité, on peut l'afficher ailleurs en direct. */}
      <p>Bonjour {fullName || 'inconnu'} — {fullName.length} caractères</p>
    </>
  );
}

Note htmlFor au lieu de for, mot réservé de JavaScript — l'un des deux décalages HTML/JSX vus dans le module React web ↔ React Native.

Le cycle complet, frappe par frappe

Il faut absolument que tu puisses dérouler cette boucle à voix haute en entrevue. fullName vaut "Patr", l'utilisateur tape i : (1) le navigateur insère provisoirement le caractère dans le champ du DOM ; (2) il émet un événement input que React, à l'écoute depuis la racine, transforme en appel de ton onChangee.target.value vaut "Patri" ; (3) setFullName("Patri") note la demande ; (4) React rappelle NameField, dont le JSX produit cette fois <input value="Patri" /> ; (5) React écrit "Patri" dans le champ réel.

La boucle d'une seule frappe, et par où elle se referme
  1. le DOMTu tapes « i »le navigateur insère le caractère dans le champ
  2. le DOMÉvénement inputReact l'écoute depuis la racine
  3. ton codeonChange(e)e.target.value vaut "Patri"
  4. ReactsetFullName("Patri")React rappelle le composant, le JSX produit value="Patri"
  5. le DOMReact réécrit le champmême si la lettre y est déjà
value — l'état réimposé
La colonne de droite est la boucle : ce qui sort de l'état à l'étape 5 revient écraser le champ d'où la frappe est partie. La lettre que tu vois n'est pas celle que tu as tapée — c'est celle que React a décidé d'afficher, après un aller-retour complet. De là viennent les deux conséquences du contrôlé : tu peux transformer la valeur au passage (majuscules, masque, compteur), et chaque frappe coûte un rendu.

Le point à saisir, et il est contre-intuitif : à l'étape 5, React réécrit la valeur du champ, même si elle est déjà correcte. Il ne « laisse pas » le caractère que l'utilisateur a tapé — il l'impose depuis l'état. La lettre que tu vois à l'écran n'est pas celle que tu as tapée : c'est celle que React a décidé d'afficher, après un aller-retour complet par l'état. C'est exactement ce qu'on veut dire par « React est la source de vérité ». Conséquence merveilleuse : puisque la valeur passe par ton code à chaque frappe, tu peux la transformer au passage (majuscules forcées, masque de téléphone, compteur de caractères, bouton activé ou non). Conséquence coûteuse : chaque frappe provoque un re-render du composant — indolore sur un champ isolé, perceptible sur un formulaire de quarante champs.

🔗 Pont — TextInput que tu connais déjà

Bonne nouvelle : tu écris déjà des champs contrôlés tous les jours. En React Native, <TextInput value={email} onChangeText={setEmail} /> est exactement le même patron. Les seules différences sont cosmétiques — c'est la table de traduction du module React web ↔ React Native, resserrée sur les champs de saisie :

React NativeReact web
<TextInput><input>
onChangeText={setEmail}onChange={(e) => setEmail(e.target.value)}
reçoit directement la chaînereçoit un objet événement ; la chaîne est dans e.target.value
pas de <form>, pas de submit<form onSubmit> et son action par défaut

React Native t'a simplifié l'API en te livrant le texte directement. Le web te donne l'événement complet, parce que tu peux en avoir besoin (e.target.name, e.target.checked, e.currentTarget…) — et on va justement s'appuyer là-dessus plus bas. Retiens la traduction : onChangeTextonChange + e.target.value.

✍️ Exercice de lecture

Une recrue écrit ce champ dans RendezVous. En test manuel, impossible de taper quoi que ce soit dedans : le champ reste désespérément vide.

const [fullName, setFullName] = useState('');

return <input value={fullName} />;

Question : pourquoi le champ est-il « gelé » ? Et pourquoi le même code avec useState('Patrick') afficherait « Patrick » sans jamais permettre de le modifier ?

Voir le corrigé

Parce que le champ est contrôlé à moitié : une value, pas de onChange. Déroule la boucle des cinq étapes. L'utilisateur tape → le navigateur insère le caractère → React n'a aucun gestionnaire, donc fullName ne change pas → mais React re-synchronise quand même le DOM avec la valeur de l'état (la chaîne vide) → le caractère est effacé, instantanément. Avec useState('Patrick'), même mécanique : le champ affiche « Patrick » et refuse toute modification, en pratique en lecture seule.

La règle à retenir : value et onChange forment un couple indissociable. React affiche d'ailleurs un avertissement explicite — « You provided a `value` prop to a form field without an `onChange` handler » — en suggérant readOnly si c'était l'intention. Pour une simple valeur de départ modifiable, ce n'est pas value qu'il faut mais defaultValue : la section suivante.

La même boucle, sans onChange — pourquoi le champ paraît gelé
  1. le DOMTu tapes « i »le caractère apparaît vraiment dans le champ
  2. le DOMÉvénement inputReact l'écoute depuis la racine
  3. le DOMAucun onChangerien ne remonte : l'état vaut toujours '' la marche qui manque
  4. React réimpose sa valuele caractère est effacé dans la foulée
value — la chaîne vide
Compare avec la figure précédente : la boucle se referme toujours, mais depuis un état que rien n'a mis à jour. Le champ n'est donc pas « bloqué » — la frappe entre bel et bien, elle est écrasée un instant plus tard par une valeur périmée. C'est pour ça que value et onChange forment un couple : la première impose, la seconde est le seul chemin de retour. Pour une simple valeur de départ modifiable, c'est defaultValue qu'il faut, qui n'impose rien.

Le composant non contrôlé : on laisse le DOM faire son travail

L'autre approche consiste à… ne rien faire. Le champ se débrouille comme en HTML pur : le DOM garde la valeur, React ne re-render pas, et on lit la valeur seulement au moment où on en a besoin — typiquement à la soumission. Pour une valeur de départ sans prendre le contrôle, on utilise defaultValue (et defaultChecked pour les cases et les radios), lu au premier rendu seulement, comme la valeur initiale de useState : le changer plus tard ne modifie pas le champ.

app/rendez-vous/SimpleForm.tsx
import { useRef } from 'react';

export function SimpleForm() {
  // useRef = une boîte stable qui ne provoque AUCUN re-render quand on la change.
  const nameRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    // On va CHERCHER la valeur dans le DOM, au moment précis où on en a besoin.
    const fullName = nameRef.current?.value ?? '';
    console.log('Nom saisi :', fullName);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="nom">Nom complet</label>
      {/* Pas de `value`, pas de `onChange` : zéro re-render pendant la frappe. */}
      <input id="nom" name="fullName" ref={nameRef} defaultValue="" />
      <button type="submit">Réserver</button>
    </form>
  );
}

useRef te donne une poignée vers l'élément DOM réel — même outil que pour déplacer le focus dans le module useState & useEffect, autre usage. La règle ne change pas : useRef pour ce qu'on retient sans redessiner.

Quand le non contrôlé est le bon choix

Le non contrôlé a mauvaise presse chez les débutants, qui l'ont appris comme « la vieille façon ». C'est faux, et le dire en entrevue est un excellent signal de maturité. C'est le bon choix quand :

  • Le formulaire est simple et rien ne se passe pendant la frappe. Un « nom, email, message, envoyer » n'a aucune raison de re-render à chaque lettre.
  • Le formulaire est gros. Trente champs contrôlés dans un seul composant : chaque frappe redessine les trente, plusieurs fois par seconde en frappe rapide. Le non contrôlé rend ce coût nul, React étant absent de la boucle.
  • Le champ est un type="file". Pas le choix : pour des raisons de sécurité, la valeur d'un champ fichier ne peut pas être fixée par JavaScript. Il est toujours non contrôlé.
  • Tu intègres une librairie non-React (éditeur de texte riche, widget de carte) qui gère son propre DOM.

Et il est le mauvais choix dès que l'interface doit réagir pendant la frappe :

  • Bouton d'envoi désactivé tant que le formulaire est incomplet.
  • Compteur de caractères restants, jauge de force du mot de passe.
  • Recherche instantanée qui filtre une liste à chaque lettre.
  • Formatage en direct (masque de téléphone, séparateurs de milliers).
  • Message d'erreur qui apparaît et disparaît au fil de la saisie.
  • Un champ dont la valeur dépend d'un autre champ (choisir un service change les créneaux).

La ligne de partage n'a rien à voir avec la taille du formulaire ni avec la mode. Elle tient en une question : est-ce que quelque chose à l'écran doit changer entre deux frappes ? Si oui, contrôlé. Si non, laisse le DOM tranquille.

🧠 Quiz éclair

Deux champs pré-remplis avec le email du patient. Il repère une faute de frappe et tente de la corriger dans chacun. Que se passe-t-il dans l'un, et dans l'autre ?

<input value={patient.email} />
<input defaultValue={patient.email} />

Le premier refuse toute modification. Il a une value sans onChange : à chaque frappe, le navigateur insère le caractère, React ne reçoit aucune demande de changement, puis re-synchronise le champ avec la valeur qu'il tient pour vraie — le caractère est effacé dans la foulée. Contrôlé à moitié, le champ est en lecture seule de fait. React le signale en console (« You provided a `value` prop to a form field without an `onChange` handler ») et suggère readOnly si c'était l'intention.

Le second se corrige normalement. defaultValue n'est lu qu'au premier rendu — exactement comme la valeur initiale d'un useState — et le DOM garde ensuite la valeur tout seul. Toute la différence tient là : « React impose » ou « React amorce, puis laisse faire ». Pour un pré-remplissage modifiable, c'est defaultValue ; pour une valeur pilotée, c'est value et onChange, jamais l'un sans l'autre.

FormData : lire tout le formulaire d'un coup

Une ref par champ, ça va pour deux champs ; à dix, c'est fastidieux. Le navigateur a heureusement l'outil qu'il faut depuis longtemps — c'est même lui qui s'en sert pour envoyer les formulaires natifs : FormData.

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();

  // currentTarget = l'élément sur lequel le gestionnaire est posé, donc le <form>.
  const formData = new FormData(e.currentTarget);

  // FormData est itérable : Object.fromEntries en fait un objet ordinaire.
  const values = Object.fromEntries(formData);
  // → { fullName: 'Patrick', email: 'p@exemple.ca', service: 'physio', notes: '' }

  console.log(values);
}

Deux lignes pour récupérer l'intégralité d'un formulaire, quel que soit son nombre de champs. C'est la manière moderne, et elle impressionne en entrevue parce qu'elle montre que tu connais la plateforme, pas seulement React.

new FormData(unFormulaire) collecte tous les champs qui ont un name — littéralement le même algorithme que le navigateur dans la section « Le formulaire natif, avant même de parler de React ». D'où l'importance capitale de cet attribut : sans name, un champ est invisible pour FormData. C'est l'oubli numéro un, et il est vicieux — le champ s'affiche et se remplit parfaitement, puis disparaît au moment de l'envoi.

Le tamis : ce qui a un name, et ce qui n'en a pas
le formulaire, tel qu'il s'affiche
<input name="fullName">
<input name="email">
<textarea id="notes">
ce que new FormData(form) en tire
fullName: 'Patrick'
email: 'p@exemple.ca'
Trois champs entrent, deux ressortent. Le troisième porte un id — utile au <label htmlFor> et au CSS, invisible à la collecte. C'est le même algorithme que le navigateur applique depuis 1995 : seuls les champs nommés partent. Le piège tient à ce que rien ne manque à l'écran — le champ s'affiche, se remplit, se relit dans un état React ; il ne disparaît qu'à l'instant de l'envoi.

Deux subtilités, questions pièges classiques :

  • Une case à cocher non cochée est simplement absente de FormData — pas false, absente ; cochée sans attribut value, elle vaut la chaîne "on". On teste donc avec formData.has('newsletter').
  • Object.fromEntries écrase les doublons. Si plusieurs champs partagent le même name (groupe de cases, <select multiple>), tu ne gardes que le dernier : utilise formData.getAll('nomDuChamp'), qui renvoie un tableau.
Contrôlé (useState)FormData (non contrôlé)
Quantité de codeUn état + un handler + 2 attributs par champDeux lignes au total
Re-renders à la frappeUn par frappeAucun
UI réactive pendant la saisieFacile et naturelImpossible sans repasser au contrôlé
Typage TypeScriptSolide : ton objet d'état est typéFaible : tout ressort en string (ou File)
Réinitialiser / pré-remplir dynamiquementTrivial : tu changes l'étatPasse par form.reset() ou une key
Compatible Server ActionsNécessite un pontNativement : c'est le format attendu

Sois honnête sur ce tableau en entrevue : FormData n'est pas « mieux », il est différent — il gagne sur la concision et la performance, il perd sur le typage et la réactivité. Un développeur expérimenté choisit selon le besoin ; un débutant applique toujours la même recette.

🧠 Quiz éclair

La note du patient s'affiche parfaitement à l'écran, et l'état React la contient. Elle n'arrive pourtant jamais au serveur. Regarde ce que values contient réellement.

function handleSubmit(e) {
  e.preventDefault();
  const values = Object.fromEntries(new FormData(e.currentTarget));
  send(values);   // → { fullName: 'Patrick', email: 'p@exemple.ca' }
}

return (
  <form onSubmit={handleSubmit}>
    <input name="fullName" />
    <input name="email" type="email" />
    <textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
    <button type="submit">Réserver</button>
  </form>
);

Le <textarea> porte un id, pas de name. Or new FormData(form) applique exactement l'algorithme du navigateur : il ne collecte que les champs nommés. Sans name, le champ est invisible à la collecte — il s'affiche, il se remplit, il vit dans un état React… et il disparaît au moment précis de l'envoi. L'id ne sert qu'au <label htmlFor> et au CSS ; il ne remplace jamais le name.

Le vrai enseignement est plus profond que l'attribut oublié : ce formulaire a deux sources de vérité. Deux champs vivent dans le DOM et se lisent au submit, un troisième vit dans useState — et la collecte ne connaît que la première. Deux issues : ajouter name="notes" (le champ peut rester contrôlé si tu tiens à sa réactivité, il redevient simplement visible pour FormData), ou choisir un seul mode de lecture pour tout le formulaire.

Un seul état pour plusieurs champs

Revenons au contrôlé, pour résoudre son problème le plus visible : l'explosion du code. Cinq champs, cinq useState — c'est ce que fait l'écran d'inscription d'Halterofit, et c'est très bien. Mais à quinze champs, tu as quinze états et quinze fonctions fléchées quasi identiques. La parade, le patron à mémoriser de ce module : un seul état sous forme d'objet où chaque clé porte le name d'un champ, et un seul gestionnaire qui se sert de ce name pour savoir quelle clé mettre à jour.

📖 La formule

Un état pour tout le formulaire

const [values, setValues] = useState({});

const handleChange = (e) =>
  setValues({ ...values, [e.target.name]: e.target.value });

Un seul handleChange dessert N champs : on le branche tel quel sur chaque onChange, et c'est l'attribut name du champ qui dit quelle clé modifier. On la reverra enrichie dans le module Formulaires 2 — validation & erreurs, où le même handler effacera aussi l'erreur du champ touché.

Décortiquer la formule

L'objet-état et le { ...state, [k]: v }, tu les écris déjà tous les jours avec Zustand. Deux détails méritent quand même un arrêt. La clé calculée [e.target.name] d'abord : les crochets, dans un littéral d'objet, signifient « évalue cette expression et utilise le résultat comme nom de clé ». Sans eux, { name: 'Patrick' } crée une clé littéralement appelée name ; avec eux, { [name]: 'Patrick' } crée une clé appelée ce que contient la variable — donc fullName ou email selon le champ qui a déclenché l'événement. C'est du JavaScript standard, pas du React, et c'est ce qui permet à un seul handler de servir tout le formulaire.

L'ordre du spread ensuite — le seul vrai piège : ce qui vient après l'étalement gagne. { [e.target.name]: e.target.value, ...values } laisse l'ancien objet écraser ta nouvelle valeur, et rien ne change jamais. Un bug redoutable, parce qu'il ressemble à du code correct.

🔗 Pont — l'immuabilité, encore et toujours

Pourquoi ne pas muter directement ? Pour la raison établie dans useState & useEffect : React compare les objets par référence, pas par contenu. Mute l'objet existant, redonne-le à React, il voit la même référence, conclut « rien n'a changé » et ne redessine pas — ton état est correct, l'écran ne bouge pas. D'où la règle qui traverse tout React : on ne mute jamais un état, on en fabrique une copie modifiée[...items, nouvel] et non items.push(nouvel).

📅 Dans RendezVous

Voici le formulaire de réservation refait avec la formule. Compare le volume de code avec ce qu'auraient donné cinq useState et cinq gestionnaires :

app/rendez-vous/AppointmentForm.tsx
type Values = {
  fullName: string; email: string; phone: string;
  service: string; notes: string;
};

const EMPTY: Values = { fullName: '', email: '', phone: '', service: 'physio', notes: '' };

export function AppointmentForm() {
  const [values, setValues] = useState<Values>(EMPTY);

  // UN SEUL gestionnaire pour les 5 champs, grâce à l'attribut `name`.
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
  ) => setValues({ ...values, [e.target.name]: e.target.value });

  return (
    <form onSubmit={handleSubmit}>
      <input name="fullName" value={values.fullName} onChange={handleChange} />
      <input name="email" type="email" value={values.email} onChange={handleChange} />
      <input name="phone" type="tel" value={values.phone} onChange={handleChange} />
      <select name="service" value={values.service} onChange={handleChange}>
        <option value="physio">Physiothérapie</option>
        <option value="massage">Massothérapie</option>
      </select>
      <textarea name="notes" value={values.notes} onChange={handleChange} />
      <button type="submit">Réserver</button>
    </form>
  );
}

Remarque trois choses. D'abord, <select> et <textarea> se contrôlent exactement comme un <input> : React a uniformisé leur API. En HTML pur, un <select> se pilote avec selected sur l'option et un <textarea> avec son contenu textuel ; React remplace les deux par value, et c'est une vraie simplification. Ensuite, chaque champ a un name qui correspond exactement à une clé de Values : c'est ce qui fait tenir tout l'édifice. Enfin, le type Values permet à TypeScript de te prévenir si tu écris values.mail au lieu de values.email.

⚠️ Piège fréquent — le champ qui change de camp

Un jour tu verras cet avertissement : « A component is changing a controlled input to be uncontrolled ». Il apparaît quand la value d'un champ passe d'une vraie chaîne à undefined (ou null) en cours de vie : pour React, un champ dont la value vaut undefined n'est pas contrôlé — il vient donc de changer de nature en plein vol, ce qu'il n'a pas le droit de faire.

Deux causes typiques : (a) tu initialises avec useState({}), donc au premier rendu values.email est undefined ; (b) tu réinitialises avec setValues({}) et toutes les clés disparaissent d'un coup. Le symptôme visible est bizarre : le champ semble figé, perd sa saisie, ou refuse de se vider. La parade vaut comme règle générale : initialise et réinitialise toujours avec un objet complet, toutes les clés à chaîne vide — c'est le rôle de la constante EMPTY ci-dessus. En dépannage, value={values.email ?? ''} fait le travail, mais c'est un pansement.

🧠 Quiz éclair

Tu tapes dans le champ : rien ne bouge à l'écran. Un console.log(values) placé juste après montre pourtant la bonne valeur. Que se passe-t-il ?

const handleChange = (e) => {
  values[e.target.name] = e.target.value;
  setValues(values);
};

L'objet a été muté sur place, puis redonné à React tel quel. React compare l'ancien et le nouvel état par référence, pas par contenu : c'est le même objet, donc « rien n'a changé », donc aucun re-render. Ton état est correct — le console.log le prouve — mais l'écran affiche encore le rendu précédent. Et comme le champ est contrôlé, il réaffiche la value d'un rendu périmé : la saisie a l'air d'être refusée, ce qui envoie chercher le bug du mauvais côté.

Correctif : setValues({ ...values, [e.target.name]: e.target.value }) — un nouvel objet, donc une nouvelle référence. Et surveille l'ordre : la clé calculée doit venir après l'étalement. Écrite avant, l'ancien objet recouvre la nouvelle valeur et le symptôme est rigoureusement le même, avec en prime l'apparence d'un code correct.

Le submit : intercepter, envoyer, réinitialiser

On a les données. Reste à les envoyer correctement — et c'est là que se joue une bonne moitié des points d'un test technique, parce que c'est là qu'on distingue le code « ça marche sur ma machine » du code qu'on met en production.

onSubmit sur le <form>, jamais onClick sur le bouton

Règle numéro un, et question d'entrevue la plus fréquente du sujet. Beaucoup de débutants écrivent <button onClick={handleSubmit}> : ça a l'air de fonctionner, c'est pourtant un bug, pour plusieurs raisons cumulées.

  • La touche Entrée. Le navigateur implémente la soumission implicite : appuyer sur Entrée depuis n'importe quel champ texte déclenche le submit. C'est le geste par défaut de tout utilisateur habitué, et il ne passe jamais par le onClick de ton bouton. Ton gestionnaire n'est donc pas appelé, mais l'action par défaut du navigateur, elle, s'exécute : la page se recharge et tout est perdu.
  • La validation native. Les attributs comme required ne sont vérifiés qu'au moment du submit. Un onClick court-circuite ce contrôle et rend tes attributs de validation décoratifs.
  • L'accessibilité, l'assistance et les frameworks. Lecteurs d'écran, gestionnaires de mots de passe, outils d'autoremplissage, librairies de formulaires et Server Actions de Next.js se branchent tous sur l'événement submit d'un vrai formulaire. Un onClick te coupe de tout cet écosystème.

La bonne structure est donc toujours la même : <form onSubmit={handleSubmit}> avec, à l'intérieur, un <button type="submit">. Et méfie-toi d'un détail : dans un <form>, un <button> sans attribut type vaut type="submit". Un bouton « Annuler » ou « Ajouter une ligne » doit donc porter type="button" — sinon il soumet le formulaire, et tu passeras un temps fou à comprendre pourquoi « Annuler » envoie la réservation.

Le gestionnaire, dans sa version la plus simple

app/rendez-vous/AppointmentForm.tsx
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();   // ① le geste sans lequel la page se recharge
  await sendAppointment(values);
  setValues(EMPTY);     // ② reset : on repart d'un objet COMPLET, pas de {}
}

Le chemin heureux, et rien d'autre : on intercepte, on envoie, on remet à zéro.

Ces deux lignes suffisent tant que tout se passe bien — et c'est précisément leur limite. Elles ne disent rien de ce qui arrive quand le serveur répond 500, quand le réseau tombe, ou quand l'utilisateur reclique parce qu'il ne voit rien bouger. Ces questions-là ne relèvent pas de la collecte des valeurs, qui est le sujet de ce module, mais de l'issue de l'envoi : c'est tout le propos du module Formulaires 2 — validation & erreurs, qui reprend exactement ce gestionnaire et lui ajoute la garde anti double-envoi, la vérification de res.ok et les quatre états d'une soumission. La ligne de partage est là : ici on récolte, là-bas on juge et on encaisse l'échec.

Réinitialiser après un succès

Trois techniques, par ordre de préférence :

  1. Formulaire contrôlé : remets l'état à sa valeur initiale. setValues(EMPTY) — et surtout pas setValues({}), pour la raison exposée dans l'encadré « le champ qui change de camp ». Propre, explicite, testable.
  2. Formulaire non contrôlé : e.currentTarget.reset(), la méthode native du <form>. Attention après un await : React recycle les objets événement et e.currentTarget peut être devenu null — garde une référence dans une variable avant.
  3. Le truc de la key. Changer la key d'un élément dit à React « ce n'est plus le même » : il démonte l'ancien sous-arbre et en monte un neuf, états frais compris. Avec un compteur formKey incrémenté après chaque succès, <AppointmentForm key={formKey} /> se réinitialise intégralement — y compris les états internes des composants enfants qu'on ne contrôle pas.
// Ce compteur vit dans le PARENT du formulaire : c'est lui qui tient la clé.
const [formKey, setFormKey] = useState(0);

// L'enfant prévient son parent quand l'envoi a réussi ; le parent incrémente
// la clé. Nouvelle clé = autre élément aux yeux de React : il jette l'ancien
// formulaire et en monte un neuf, tous les états remis à zéro.
return <AppointmentForm key={formKey} onSuccess={() => setFormKey((k) => k + 1)} />;

Le « remontage par la key » est une technique officielle, pas un bricolage. Très utile en édition : key={patientId} et le formulaire se réinitialise seul quand on change de patient, sans le moindre useEffect.

✍️ Exercice de lecture

Un formulaire de RendezVous a été livré avec ce gestionnaire. En testant, l'utilisateur rapporte : « je clique sur Réserver, la page clignote et revient vide, et je ne sais même pas si c'est parti ».

async function handleSubmit(e) {
  const res = await fetch('/api/appointments', {
    method: 'POST',
    body: JSON.stringify(values),
  });
  setConfirmed(true);
}

return (
  <form onSubmit={handleSubmit}>
    {/* ... champs ... */}
    <button type="submit">Réserver</button>
  </form>
);

Questions : (1) Que voit exactement l'utilisateur, et pourquoi ? (2) L'appel fetch part-il quand même ? (3) Combien d'autres défauts vois-tu dans ces quelques lignes ?

Voir le corrigé

(1) Il manque e.preventDefault(). L'action par défaut s'exécute donc : le navigateur collecte les champs, envoie une requête vers l'URL courante (pas d'action, donc la page elle-même) et recharge la page. Visuellement : un clignotement, puis un formulaire vide. Tout l'état React est détruit, donc setConfirmed(true) n'a aucun effet visible — le composant qui devait l'afficher n'existe plus.

(2) Peut-être, peut-être pas — le pire des mondes. Le fetch est lancé, mais la navigation démarre au même instant et le navigateur a le droit d'annuler les requêtes en cours quand il quitte la page. Selon le timing et le réseau, la réservation part parfois et parfois non : un bug non déterministe.

(3) Au moins quatre autres : (a) pas de vérification de res.ok — confirmation affichée même sur une erreur 500 ; (b) pas de try/catch — une coupure réseau produit une promesse rejetée non gérée ; (c) pas d'isSubmitting ni de bouton désactivé — double-clic garanti, donc doublons en base ; (d) pas d'en-tête Content-Type: application/json alors qu'on envoie du JSON. Chacun de ces points est une case à cocher chez l'évaluateur. Si tu n'as vu que (d), c'est normal à ce stade : les trois premiers relèvent de l'issue de l'envoi, et le module Formulaires 2 — validation & erreurs les traite un par un. Reviens relire ce corrigé après l'avoir lu — tu devrais alors les repérer tous les quatre sans effort.

Le formulaire piloté par des données

Voici l'exercice que tu as de bonnes chances de rencontrer tel quel : « on te donne une configuration JSON qui décrit des champs, génère le formulaire ». Le principe : décrire chaque champ comme un objet de données (type, name, libellé) au lieu de l'écrire à la main dans le JSX. Le formulaire devient une fonction de sa configuration, et peut donc venir d'une API ou d'un CMS et évoluer sans redéploiement.

app/rendez-vous/champs.json
[
  { "type": "text",     "name": "fullName", "label": "Nom complet",  "required": true },
  { "type": "email",    "name": "email",    "label": "Courriel",     "required": true },
  { "type": "tel",      "name": "phone",    "label": "Téléphone" },
  { "type": "date",     "name": "date",     "label": "Date souhaitée" },
  { "type": "select",   "name": "service",  "label": "Service",
    "options": [
      { "value": "physio",  "label": "Physiothérapie" },
      { "value": "massage", "label": "Massothérapie" }
    ] },
  { "type": "textarea", "name": "notes",    "label": "Précisions" },
  { "type": "checkbox", "name": "rappel",   "label": "M'envoyer un rappel la veille" }
]

Sept champs décrits en données. Ajouter un champ, c'est ajouter une ligne ici — pas toucher au composant.

Le composant qui rend cette config tient en trois idées. Un switch sur field.type, dont chaque branche produit le bon élément — dont la case à cocher, seul cas particulier, qui se lit dans checked et non dans value. Une branche default qui est le trait de génie du patron : pour tous les champs qui sont de simples <input>, la chaîne de configuration est l'attribut type du HTML, donc prendre en charge "password" ou "url" ne demande aucune ligne de code — juste d'élargir le type TypeScript. Et key={field.name} sur la boucle plutôt que l'index : un name est unique et stable par nature, un index change dès que la liste est filtrée ou réordonnée. Détail final qui vaut des points en test technique : construis l'état initial depuis la config (useState(() => Object.fromEntries(...))) — toutes les clés existent dès le premier rendu, ce qui t'immunise contre le piège du contrôlé/non contrôlé.

🧠 Quiz éclair

L'utilisateur coche « M'envoyer un rappel la veille », puis se ravise et décoche. Que contient values.reminder après ces deux clics, et que voit-il à l'écran ?

const handleChange = (e) =>
  setValues({ ...values, [e.target.name]: e.target.value });

<input
  type="checkbox"
  name="reminder"
  checked={values.reminder}
  onChange={handleChange}
/>

values.reminder vaut la chaîne 'on'dans les deux cas. Sur une case à cocher, e.target.value ne dit rien de l'état coché : il vaut l'attribut value du champ, ou à défaut la chaîne "on", qu'on coche ou qu'on décoche. L'information vit ailleurs, dans e.target.checked, un booléen. À l'écran : checked={'on'} est truthy, donc la case se coche au premier clic et refuse ensuite de se décocher.

Le handler unique de la formule dessert tous les champs qui portent leur valeur dans value — texte, email, date, select, textarea — et ceux-là seulement. Pour absorber la case, il ne manque qu'une condition : [e.target.name]: e.target.type === 'checkbox' ? e.target.checked : e.target.value. C'est exactement pourquoi le composant piloté par la config traite la case à part : ce n'est pas un caprice de patron générique, c'est le seul type de champ dont la valeur ne se lit pas au même endroit.

Contrôlé ou non : trancher, et savoir le dire

On revient à la question du début. Voici le tableau de décision à garder en tête ; il couvre à peu près toutes les situations que tu rencontreras.

Ta situationLe choixPourquoi
Recherche instantanée, filtre en directContrôléL'affichage doit changer à chaque frappe.
Bouton désactivé tant que c'est incompletContrôléReact doit connaître la valeur en continu.
Erreur affichée pendant la saisieContrôléLa validation est une fonction de la valeur courante.
Formatage en direct (téléphone, montant)ContrôléOn transforme la valeur au passage.
Champs interdépendantsContrôléUn champ doit pouvoir réagir à un autre.
Formulaire de contact « remplis puis envoie »Non contrôléRien à faire avant le submit ; moins de code.
Très gros formulaire, saisie fluide exigéeNon contrôléZéro re-render pendant la frappe.
Champ type="file"Non contrôléImposé par la sécurité du navigateur.
Formulaire envoyé à une Server ActionNon contrôléFormData est le format natif attendu.
Intégration d'un widget non-ReactNon contrôléLa librairie gère son propre DOM.

Et une nuance qu'on oublie souvent : ce n'est pas un choix « tout ou rien ». Un champ de recherche contrôlé peut très bien vivre au milieu d'un formulaire par ailleurs non contrôlé lu avec FormData. Le contrôle se décide champ par champ.

🧠 Quiz éclair

Exercice de classement, avec un piège : ce champ est-il contrôlé ou non contrôlé ? Tranche avant d'ouvrir, puis décris le symptôme que l'utilisateur va rapporter.

const [values, setValues] = useState({});

<input
  name="email"
  value={values.email}
  onChange={(e) => setValues({ ...values, email: e.target.value })}
/>

Les deux, successivement — et c'est précisément le bug. Au premier rendu, values.email vaut undefined : pour React, une value à undefined signifie « je ne pilote pas ce champ », donc il est non contrôlé. À la première frappe, la clé apparaît, la value devient une chaîne, et le champ passe contrôlé en plein vol. React signale ce changement de camp par le message symétrique de celui de l'encadré plus haut : « A component is changing an uncontrolled input to be controlled ».

Ce que l'utilisateur rapporte, lui, n'a rien de technique : un champ « bizarre » — première lettre avalée, curseur qui saute en fin de texte, ou champ qui refuse de se vider après une réinitialisation. Aucune exception, aucune ligne rouge : le pire genre de bug, celui qui ne se nomme pas. La parade vaut dans les deux sens — initialise et réinitialise toujours avec un objet complet, toutes les clés présentes à chaîne vide. value={values.email ?? ''} dépanne, mais ne fait que masquer une structure de données incomplète.

🎤 En entrevue

« Quelle est la différence entre un composant contrôlé et un composant non contrôlé ? »

« La différence, c'est qui détient la valeur. Dans un composant contrôlé, la valeur vit dans un état React : le champ reçoit value et onChange, chaque frappe met l'état à jour, et React réimpose la valeur au champ. React est la source de vérité. Dans un composant non contrôlé, c'est le DOM qui garde la valeur, comme en HTML pur ; on lui donne au plus un defaultValue, et on lit la valeur au moment du submit, avec une ref ou avec FormData. »

« Le contrôlé coûte un re-render par frappe mais permet de réagir à chaque caractère : UI conditionnelle, formatage, validation en direct. Le non contrôlé ne coûte rien mais ne permet rien pendant la saisie. Donc en pratique, je me pose une seule question : est-ce que l'interface doit réagir à chaque frappe ? Si oui, contrôlé ; si non, non contrôlé — et je peux mélanger les deux dans un même formulaire. »

« Pourquoi mettre onSubmit sur le formulaire plutôt que onClick sur le bouton ? »

« Surtout à cause de la touche Entrée. Le navigateur soumet un formulaire quand on appuie sur Entrée depuis un champ texte — c'est la soumission implicite, et c'est le geste par défaut de beaucoup d'utilisateurs. Cette soumission ne passe pas par le onClick du bouton : mon code ne s'exécuterait pas, et comme personne n'appelle preventDefault, la page se rechargerait et tout serait perdu. »

« Il y a aussi trois bénéfices en prime : la validation native (required) ne se déclenche qu'au submit ; les gestionnaires de mots de passe et les lecteurs d'écran s'appuient sur l'événement submit ; et les Server Actions de Next.js se branchent sur le <form>. Détail bonus : dans un formulaire, je mets type="button" sur tout bouton qui n'est pas le bouton d'envoi, parce que le défaut est submit. »

« Ton formulaire a vingt champs. Comment tu gères ça ? »

« Je ne fais surtout pas vingt useState. Je mets un seul état objet dont les clés portent le name des champs, et un seul gestionnaire : setValues({ ...values, [e.target.name]: e.target.value }). La clé calculée entre crochets permet au même handler de servir les vingt champs. Deux précautions : j'initialise avec un objet complet, toutes les clés à chaîne vide, pour ne pas basculer de contrôlé à non contrôlé ; et je crée un nouvel objet plutôt que de muter l'ancien, sinon React ne voit pas le changement et l'écran ne bouge pas. »

« Ensuite, ça dépend du contexte. Si l'UI n'a rien à faire pendant la saisie, je passerais plutôt en non contrôlé avec FormData — avec vingt champs contrôlés, chaque frappe redessine les vingt champs. Et sur un vrai projet, à ce niveau de complexité, je proposerais React Hook Form : il est non contrôlé par défaut, donc performant, et il apporte la validation. »

À retenir

Un <form> natif collecte les champs qui ont un name, envoie une requête et recharge la page : en React, on annule ça avec e.preventDefault() dans un onSubmit posé sur le formulaire, jamais sur le bouton (à cause de la touche Entrée). Un champ contrôlé = value + onChange + useState : React est la source de vérité, un re-render par frappe, et tout devient possible pendant la saisie. Un champ non contrôlé = defaultValue, le DOM garde la valeur, on la lit au submit avec une ref ou new FormData(e.currentTarget) + Object.fromEntries. Pour N champs, la formule : un état objet + un handler unique { ...values, [e.target.name]: e.target.value }, avec un objet initial complet et jamais de mutation. Et à l'envoi : garde anti double-clic, vérification de res.ok, finally pour sortir du chargement, reset après succès.

Et ailleurs : la question « qui détient la donnée ? » dépasse largement les formulaires. C'est la question centrale de toute architecture d'interface : une valeur doit avoir un seul propriétaire, les autres n'en voient qu'un reflet. Tu la recroiseras sous les noms de single source of truth, lifting state up, two-way binding en Vue ou Angular, @State et @Binding en SwiftUI. Chaque fois que deux endroits de ton code peuvent modifier la même donnée, tu as un bug de synchronisation en germe. Second réflexe transférable, plus humble et tout aussi rentable : regarde ce que la plateforme fait déjà avant d'installer quoi que ce soit — type="date", autocomplete, FormData, la soumission implicite sont gratuits, testés, accessibles et traduits. Le meilleur code est souvent celui qu'on n'écrit pas.

💡 Ce qui vient ensuite

Tu sais maintenant capturer ce que l'utilisateur saisit. Reste la moitié la plus délicate : décider si c'est valide, et le dire bien. Quand valider ? Où afficher le message pour qu'un lecteur d'écran l'annonce ? Comment gérer les erreurs du serveur ? C'est le programme du module Formulaires 2 — validation & erreurs, qui reprend le formulaire RendezVous là où on le laisse ici.

🗂️ L'aide-mémoire
value / defaultValue
value exige onChange ; defaultValue n'est lu qu'au premier rendu
Case à cocher
se lit dans e.target.checked (booléen), jamais dans value
Case non cochée + FormData
absente de la collecte, pas false ; cochée sans value, elle vaut "on"
Plusieurs champs, même name
formData.getAll('nom')Object.fromEntries ne garde que le dernier
Ordre du spread
la clé calculée vient après : { ...values, [e.target.name]: e.target.value }
<button> dans un <form>
sans attribut type, il vaut type="submit" — « Annuler » prend type="button"
new FormData(...)
prend e.currentTarget (le <form> qui porte le handler), et ne ramasse que les champs nommés
type="file"
toujours non contrôlé : JavaScript n'a pas le droit d'en fixer la valeur