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.
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 :
-
Il parcourt tous les champs et retient ceux qui ont un attribut
name. Les autres sont purement et simplement ignorés. -
Il construit une paire
name/valuepour chacun et les encode (par défaut enapplication/x-www-form-urlencoded, qui ressemble àfullName=Patrick&email=p%40exemple.ca). -
Il envoie une vraie requête HTTP vers l'URL de
action— en query string avecmethod="get", dans le corps avecmethod="post"(module HTTP & fetch). - 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.
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 ».
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.
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.
« 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.
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 onChange — e.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.
- le DOMTu tapes « i »le navigateur insère le caractère dans le champ
- le DOMÉvénement inputReact l'écoute depuis la racine
-
ton codeonChange(e)
e.target.valuevaut"Patri" -
ReactsetFullName("Patri")React rappelle le composant, le JSX produit
value="Patri" - le DOMReact réécrit le champmême si la lettre y est déjà
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.
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 Native | React web |
|---|---|
<TextInput> | <input> |
onChangeText={setEmail} | onChange={(e) => setEmail(e.target.value)} |
| reçoit directement la chaîne | reç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 :
onChangeText → onChange + e.target.value.
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.
- le DOMTu tapes « i »le caractère apparaît vraiment dans le champ
- le DOMÉvénement inputReact l'écoute depuis la racine
-
le DOMAucun onChangerien ne remonte : l'état vaut toujours
''la marche qui manque - React réimpose sa valuele caractère est effacé dans la foulée
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.
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.
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.
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— pasfalse, absente ; cochée sans attributvalue, elle vaut la chaîne"on". On teste donc avecformData.has('newsletter'). -
Object.fromEntriesécrase les doublons. Si plusieurs champs partagent le mêmename(groupe de cases,<select multiple>), tu ne gardes que le dernier : utiliseformData.getAll('nomDuChamp'), qui renvoie un tableau.
Contrôlé (useState) | FormData (non contrôlé) | |
|---|---|---|
| Quantité de code | Un état + un handler + 2 attributs par champ | Deux lignes au total |
| Re-renders à la frappe | Un par frappe | Aucun |
| UI réactive pendant la saisie | Facile et naturel | Impossible sans repasser au contrôlé |
| Typage TypeScript | Solide : ton objet d'état est typé | Faible : tout ressort en string (ou File) |
| Réinitialiser / pré-remplir dynamiquement | Trivial : tu changes l'état | Passe par form.reset() ou une key |
| Compatible Server Actions | Nécessite un pont | Nativement : 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.
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.
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.
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).
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 :
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.
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.
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 surEntréedepuis n'importe quel champ texte déclenche lesubmit. C'est le geste par défaut de tout utilisateur habitué, et il ne passe jamais par leonClickde 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
requiredne sont vérifiés qu'au moment dusubmit. UnonClickcourt-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
submitd'un vrai formulaire. UnonClickte 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
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 :
-
Formulaire contrôlé : remets l'état à sa valeur initiale.
setValues(EMPTY)— et surtout passetValues({}), pour la raison exposée dans l'encadré « le champ qui change de camp ». Propre, explicite, testable. -
Formulaire non contrôlé :
e.currentTarget.reset(), la méthode native du<form>. Attention après unawait: React recycle les objets événement ete.currentTargetpeut être devenunull— garde une référence dans une variable avant. -
Le truc de la
key. Changer lakeyd'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 compteurformKeyincré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.
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.
[
{ "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é.
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 situation | Le choix | Pourquoi |
|---|---|---|
| Recherche instantanée, filtre en direct | Contrôlé | L'affichage doit changer à chaque frappe. |
| Bouton désactivé tant que c'est incomplet | Contrôlé | React doit connaître la valeur en continu. |
| Erreur affichée pendant la saisie | Contrô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épendants | Contrô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ée | Non 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 Action | Non contrôlé | FormData est le format natif attendu. |
| Intégration d'un widget non-React | Non 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.
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.
« 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. »
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.
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.
value/defaultValuevalueexigeonChange;defaultValuen'est lu qu'au premier rendu- Case à cocher
- se lit dans
e.target.checked(booléen), jamais dansvalue - Case non cochée +
FormData - absente de la collecte, pas
false; cochée sansvalue, elle vaut"on" - Plusieurs champs, même
name formData.getAll('nom')—Object.fromEntriesne 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 vauttype="submit"— « Annuler » prendtype="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