Formulaires React construits manuellement : champs contrôlés, validation et états de soumission
Apprenez comment fonctionnent les formulaires React, des entrées contrôlées et des gestionnaires par type jusqu’à la validation, aux états de soumission, aux champs dynamiques, aux téléchargements, et à quand une bibliothèque de formulaires s’avère utile.
Sources: React Forms: A Complete Guide from Basics to Production Patterns
Connexion, paiement, recherche, paramètres du profil, enquêtes : presque toutes les applications sont basées sur des formulaires. En React, un formulaire implique la gestion des valeurs, de la validation, des messages d’erreur, de la soumission, des états de chargement et des erreurs serveur, l’ajout de champs en temps de exécution, les réinitialisations ainsi que la performance. Ce guide présente pas à pas ce fonctionnement, depuis les entrées contrôlées jusqu’à un formulaire de registration complet sans utilisation de bibliothèques, et indique quand il est utile d’intégrer React Hook Form et Zod.
Comment un formulaire fonctionne dans une application React
Un formulaire collecte des informations de l’utilisateur, comme sur cet écran de registration :
┌──────────────────────────────┐
│ Create Account │
│ │
│ Name [______________] │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Register ] │
└──────────────────────────────┘
React relie ces champs d’entrée à la logique de l’application, et presque tous les formulaires suivent le même cycle de vie :
User Input
↓
Form State
↓
Validation
↓
Submit
↓
API Request
↓
Success / Error
Composants contrôlés : l’état comme source de vérité
Dans un composant contrôlé, la value de l’entrée provient de l’état React et chaque modification est réécrite via onChange.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
}
En définissant value={email}, c’est React qui décide ce que l’élément affiche. Chaque frappe suit ce cycle :
User types
↓
onChange fires
↓
setEmail()
↓
React state updates
↓
Component renders
↓
Input receives new value
Puisque la valeur affichée provient toujours de l’état, celui-ci constitue la seule source de vérité.
Le nom « contrôlé » reflète le fait que l’entrée ne possède pas sa propre valeur :
<input
value={email}
onChange={handleChange}
/>
Les données circulent de l’état vers l’élément, via les frappes de l’utilisateur et le gestionnaire d’événements, puis retournent à l’état :
React State
│
▼
input value
│
▼
User types
│
▼
onChange
│
▼
React State
Un objet d’état pour de nombreux champs
Une appel à useState par champ entraîne des performances médiocres :
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Un seul objet identifié par le nom d’un champ est plus facile à maintenir. Cette approche regroupe trois champs dans un seul objet, partage une seule fonction handleChange et enregistre les données lors de l’envoi.
import { useState } from "react";
function RegisterForm() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
/>
<input
name="email"
value={form.email}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">
Register
</button>
</form>
);
}
La ligne clé est la propriété calculée [name]: value. L’attribut name de chaque champ d’entrée correspond à une clé d’état, ce qui permet à un seul gestionnaire de mettre à jour le champ concerné.
Pour les champs de texte, le gestionnaire lit la valeur de l’élément à partir de l’événement :
<input
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
event.target représente le champ d’entrée, donc event.target.value contient son texte actuel :
User types: Salim
event.target.value
↓
"Salim"
Gestion correcte de chaque type de champ d’entrée
Les formulaires combinent des champs de texte, de mot de passe, de nombre, des cases à cocher, des radios, des sélecteurs, des zones de texte, des fichiers, ainsi que des contrôles de date et de plage. La plupart suivent le même schéma, avec quelques différences importantes.
Texte, mot de passe et nombre
Un champ de texte lie la valeur value à sa clé :
<input
type="text"
value={form.name}
onChange={handleChange}
/>
Un champ de mot de passe ne diffère que par la valeur de type :
<input
type="password"
value={form.password}
onChange={handleChange}
/>
Il en va de même pour un champ numérique :
<input
type="number"
value={form.age}
onChange={handleChange}
/>
Le problème : event.target.value est une chaîne de caractères même avec type="number". Le fragment ci-dessous mélange code et explication, mais l’idée est qu’on obtient "25" au lieu de 25, et qu’il faut utiliser Number() pour effectuer la conversion.
const age = event.target.value;
may produce:
"25" rather than: 25
If you need a number:
const age = Number(event.target.value);
Faites attention aux valeurs vides : Number("") renvoie 0, c’est pourquoi de nombreux formulaires conservent la chaîne brute et ne la convertissent qu’au moment de la validation ou du envoi.
Les cases à cocher utilisent checked
Pour une case à cocher, liez la propriété checked à une valeur booléenne et lisez event.target.checked :
<input
type="checkbox"
checked={form.agree}
onChange={(event) => {
setForm((previous) => ({
...previous,
agree: event.target.checked,
}));
}}
/>
event.target.value sur une case à cocher renvoie son attribut value (par défaut "on"), et non le fait qu’elle soit cochée.
Groupons de sélection, de zone de texte et de boutons radio
select est contrôlé via l’attribut value de l’élément select ; une première option vide sert de placeholder.
<select
name="role"
value={form.role}
onChange={handleChange}
>
<option value="">
Select role
</option>
<option value="frontend">
Frontend Developer
</option>
<option value="backend">
Backend Developer
</option>
</select>
React traite le textarea comme un champ de saisie, avec une propriété value au lieu du texte enfant :
<textarea
name="message"
value={form.message}
onChange={handleChange}
/>
Les boutons radio d’un groupe partagent un name. Chacun est coché lorsque son value correspond à l’état actuel, et le gestionnaire commun enregistre le choix dans form.gender.
<label>
<input
type="radio"
name="gender"
value="male"
checked={form.gender === "male"}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={form.gender === "female"}
onChange={handleChange}
/>
Female
</label>
Prenir le contrôle de la soumission
Par défaut, un navigateur soumet le formulaire lui-même et peut naviguer ou recharger la page. Les applications React gèrent la soumission via du JavaScript, de sorte que le gestionnaire de soumission commence par event.preventDefault(), comme dans l’exemple d’inscription. Ensuite, le flux est à vous :
Submit
↓
preventDefault()
↓
Validate
↓
API
↓
Update UI
Vérification avant la requête
La validation s’assure que les entrées sont acceptables. Une première version collecte les erreurs en fonction du champ :
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
}
if (!form.password) {
errors.password = "Password is required";
}
return errors;
};
Le gestionnaire de soumission effectue d’abord une validation, enregistre les erreurs et quitte prématurément s’il y en a :
const handleSubmit = (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit form
};
Les erreurs sont stockées dans leur propre état :
const [errors, setErrors] = useState({});
À un moment donné, il peut contenir :
{
email: "Invalid email",
password: "Password must contain at least 8 characters"
}
Chaque champ affiche son message de manière conditionnelle :
{errors.email && (
<p>{errors.email}</p>
)}
Une meilleure version de validate prend le formulaire en argument, ce qui en fait une fonction pure et testable, et vérifie le format ainsi que la longueur dès qu’un champ est présent :
function validate(form) {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
} else if (!form.email.includes("@")) {
errors.email = "Invalid email";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 8) {
errors.password =
"Password must contain at least 8 characters";
}
return errors;
}
Le gestionnaire l’appelle de la même manière :
const errors = validate(form);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
Le test includes("@") ne détecte que les fautes d’orthographe évidentes ; la preuve réelle que l’adresse fonctionne est un e-mail de confirmation.
La validation côté client et côté serveur ont des rôles différents
La validation côté client s’exécute dans le navigateur et couvre généralement :
- Les champs obligatoires
- Le format de l’e-mail
- La longueur minimale du mot de passe
- La confirmation du mot de passe
Cela fournit une réponse rapide, améliore l’expérience utilisateur et réduit le nombre de requêtes inutiles, mais ce n’est pas une barrière de sécurité. Le serveur doit répéter les vérifications et répondre à des questions que le navigateur ne peut pas poser :
- L’e-mail est-il déjà enregistré ?
- L’utilisateur est-il autorisé ?
- Le prix est-il valide ?
- Cet utilisateur peut-il effectuer cette opération ?
Les applications sécurisées utilisent les deux approches. Ne jamais écrire de logique de ce type :
if (clientValidationPassed) {
// Data is safe
}
Les vérifications du navigateur peuvent être contournées en modifiant la page ou en appelant directement l’API, c’est pourquoi le backend valide chaque requête de manière indépendante :
Request
↓
Authentication
↓
Authorization
↓
Validation
↓
Business Logic
↓
Database
Modélisation des états de soumission
Un formulaire en production passe par des états distincts que l’interface utilisateur doit refléter :
Idle
↓
Submitting
↓
Success
or
Idle
↓
Submitting
↓
Error
Deux éléments d’état les suivent :
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");
Le gestionnaire asynchrone active le drapeau, efface les anciennes erreurs du serveur, attend la requête, enregistre un message explicite en cas d’échec, et réinitialise le drapeau dans la clause finally quel que soit le résultat.
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setServerError("");
try {
await createUser(form);
} catch (error) {
setServerError("Unable to create account");
} finally {
setIsSubmitting(false);
}
};
Désactiver et réétiqueter le bouton pendant la requête empêche les soumissions dupliquées :
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
Le cycle de vie du bouton correspond au déroulement de la requête :
User clicks Submit
↓
Request starts
↓
Button disabled
↓
Request finishes
↓
Button enabled
Entrées contrôlées versus non contrôlées
Avec une entrée contrôlée, l’état de React conserve la valeur. Le bloc ci-dessous montre la forme abrégée, un composant complet ainsi que le flux de données, séparés par des lignes d’étoiles.
<input
value={email}
onChange={handleChange}
/>
********************************************
function Form() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
********************************************
DOM event
↓
React state
↓
Render
↓
DOM value
Avec une entrée non contrôlée, le DOM conserve la valeur et React la lit via un ref, généralement lors de l’envoi du formulaire, sans enregistrer chaque frappe :
const emailRef = useRef();
<input ref={emailRef} />
********************************************
function Form() {
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
console.log(emailRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
<button type="submit">
Submit
</button>
</form>
);
}
********************************************
DOM
↓
Input value
Préférez les entrées contrôlées pour une validation immédiate, un UI conditionnel, des valeurs dérivées, du formatage, un état géré par React ou des interactions personnalisées. Comme React connaît la valeur, l’UI peut réagir instantanément :
{password.length < 8 && (
<p>Password is too short</p>
)}
Choisissez des entrées non contrôlées pour les formulaires simples, lorsque l’état à chaque frappe n’apporte rien, lorsque vous souhaitez moins de mises à jour, ou lorsque une bibliothèque gère les références à votre place. Les actions de formulaire de React 19 s’appuient également sur ce modèle ; consultez les formulaires asynchrones avec useActionState et useOptimistic.
Lorsqu’une bibliothèque de formulaires se révèle utile
React Hook Form
Connexer manuellement des dizaines de champs est fastidieux. Le register de React Hook Form relie un champ d’entrée et définit ses règles, handleSubmit valide avant d’appeler votre fonction de rappel, et formState.errors affiche les messages d’erreur.
import { useForm } from "react-hook-form";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must be at least 8 characters",
},
})}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
<button type="submit">
Login
</button>
</form>
);
}
Cela simplifie l’enregistrement, la validation, les erreurs, la soumission, les réinitialisations, les champs dynamiques et l’état du formulaire. Comme il enregistre par défaut les entrées non contrôlées, il évite de réafficher l’intégralité du formulaire à chaque frappe, contrairement à un formulaire contrôlé naïf.
Validation de schéma avec Zod
Au fur et à mesure que les règles augmentent, les instructions if dispersées deviennent difficiles à gérer. Zod décrit la structure attendue des données en une seule déclaration :
import { z } from "zod";
const userSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
});
Une combinaison courante en environnement de production consiste à utiliser la bibliothèque pour gérer l’état et le schéma pour définir les règles :
React Hook Form
+
Zod
↓
Form state + validation
Le schéma peut être aussi concis que cela :
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
React Hook Form intègre un schéma via un résolveur, ce qui convient aux formulaires volumineux dont les règles doivent rester cohérentes.
Champs dynamiques, conditionnels et dépendants
Groupons répétitifs
Les utilisateurs ajoutent souvent des entrées sur demande, comme par exemple les informations relatives à leur formation :
Education
Degree [________]
College [________]
[ + Add Education ]
Stockez-les sous forme d’array d’objets :
const [education, setEducation] = useState([
{
degree: "",
college: "",
},
]);
L’ajout d’une ligne insère un objet vide :
setEducation((previous) => [
...previous,
{
degree: "",
college: "",
},
]);
Le rendu parcourt l’array :
{education.map((item, index) => (
<div key={index}>
<input
value={item.degree}
onChange={(event) => {
// update degree
}}
/>
<input
value={item.college}
onChange={(event) => {
// update college
}}
/>
</div>
))}
Un index key n’est fiable que tant les lignes ne sont jamais supprimées ou réordonnées ; dans le cas contraire, utilisez des identifiants stables, sinon du texte de type spécifique peut être associé à la mauvaise ligne.
Champs conditionnels
Certains champs dépendent d’un choix antérieur, comme les informations de l’entreprise pour un compte professionnel :
Account Type
○ Individual
○ Business
If the user chooses:
Business
show:
Company Name
GST Number
Company Address
Une expression conditionnelle gère cela :
{form.accountType === "business" && (
<>
<input
name="companyName"
value={form.companyName}
onChange={handleChange}
/>
<input
name="gstNumber"
value={form.gstNumber}
onChange={handleChange}
/>
</>
)}
Si l’utilisateur revient en arrière, effacez ou excluez les valeurs cachées afin que des données obsolètes ne soient pas soumises.
Sélections en cascade
Pays, État et ville forment une chaîne :
Country
↓
State
↓
City
Un changement en amont réinitialise tout ce qui se trouve en dessous avant de récupérer de nouvelles options :
Country changes
↓
Clear State
↓
Fetch states
↓
User selects State
↓
Fetch cities
Puisque les options proviennent du serveur, TanStack Query s’associe bien à la bibliothèque de formulaires pour gérer le cache et les états de chargement.
Chargement de fichiers
Les champs de fichier ne peuvent pas être contrôlés ; lisez le fichier depuis event.target.files:
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
console.log(file);
}
}}
/>
Pour le télécharger, envoyez un corps de type FormData:
const formData = new FormData();
formData.append("name", form.name);
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
N’ajustez pas vous-même Content-Type: multipart/form-data avec fetch et FormData ; le navigateur génère cette en-tête avec la frontière requise.
Rendre les formulaires volumineux rapides
Les formulaires grands et complexes sollicitent fortement un formulaire naïf contrôlé qui se réredimensionne à chaque frappe :
User types
↓
setState
↓
Render
↓
Large form tree
Mesures utiles :
- Gardez l’état local plutôt que global sans raison.
- Divisez le formulaire en sections afin que chaque modification entraîne moins de réredimensionnement :
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
- Utilisez une bibliothèque adaptée telle que React Hook Form.
- Évitez des calculs lourds ou des appels API par caractère.
- Débouchez ce qui doit s’exécuter, comme la vérification de la disponibilité d’un nom d’utilisateur :
Username input
↓
Wait 300ms
↓
Check availability
Un formulaire complet sans bibliothèque
L’exemple final combine une constante initialForm pour les réinitialisations, un état séparé pour les erreurs des champs, les erreurs de soumission et celles du serveur, une fonction validate, ainsi qu’une soumission asynchrone qui envoie du JSON, considère une réponse non OK comme une erreur, réinitialise en cas de succès et efface toujours le indicateur de chargement. Les étiquettes relient les champs via htmlFor et id.
import { useState } from "react";
const initialForm = {
name: "",
email: "",
password: "",
};
function RegisterForm() {
const [form, setForm] = useState(initialForm);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [serverError, setServerError] =
useState("");
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.includes("@")) {
errors.email = "Enter a valid email";
}
if (form.password.length < 8) {
errors.password =
"Password must be at least 8 characters";
}
return errors;
};
const handleSubmit = async (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setServerError("");
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Registration failed");
}
setForm(initialForm);
} catch (error) {
setServerError(
"Unable to create your account."
);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
/>
{errors.name && (
<p>{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
{serverError && (
<p>{serverError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
</form>
);
}
export default RegisterForm;
Pour la production, définissez également aria-invalid sur les champs invalides et reliez les messages avec aria-describedby.
Tout le flux en un coup d’œil
Tout ce qui précède correspond à un seul arbre de décision, allant de l’entrée jusqu’à la validation, en passant par les erreurs de champ ou une appel API avec des branches pour le succès et l’erreur :
React Form
│
▼
User Input
│
▼
Form State
│
▼
Validation
/ \
Valid Invalid
│ │
▼ ▼
Submit Show Errors
│
▼
API Call
/ \
Success Error
│ │
▼ ▼
Reset/Form Show Server
Success UI Error
Points clés
- Les entrées contrôlées font de l’état la source de vérité ; un objet accompagné d’un gestionnaire
[name]: valuecouvre la plupart des champs. - Les nombres arrivent sous forme de chaînes, les cases à cocher utilisent l’attribut
checked, les sélecteurs sont gérés via l’élémentselect, et les champs de fichier restent toujours non contrôlés. - La validation côté client sert l’utilisateur ; seule la validation côté serveur protège les données.
- L’état de soumission du modèle est indiqué explicitement et la soumission est désactivée pendant les requêtes.
- Ajoutez React Hook Form et Zod lorsque le nombre de champs, les règles communes ou la complexité de rendu rendent difficile la gestion manuelle de l’état.