Accueil / Articles

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.

Éditeur : REACTAPP.TOP13 min de lecture2992 mots

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 :

  1. Les champs obligatoires
  2. Le format de l’e-mail
  3. La longueur minimale du mot de passe
  4. 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 :

  1. L’e-mail est-il déjà enregistré ?
  2. L’utilisateur est-il autorisé ?
  3. Le prix est-il valide ?
  4. 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 :

  1. Gardez l’état local plutôt que global sans raison.
  2. Divisez le formulaire en sections afin que chaque modification entraîne moins de réredimensionnement :
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
  1. Utilisez une bibliothèque adaptée telle que React Hook Form.
  2. Évitez des calculs lourds ou des appels API par caractère.
  3. 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]: value couvre 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ément select, 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.