Cours Next.js · Module 6: Formulaires et Server Actions

React Hook Form et la gestion de l'état des formulaires

20 min de lecture
Dans cette leçon8

Les formulaires font partie intégrante de la plupart des applications web, mais gérer efficacement leur état, leur validation et leurs erreurs peut s'avérer complexe. React Hook Form est l'une des bibliothèques les plus populaires de l'écosystème React, conçue pour résoudre les problèmes courants liés aux formulaires tout en maintenant des performances élevées et une faible complexité de code.

Pourquoi utiliser React Hook Form ?

Avant de plonger dans les détails d'implémentation, examinons les principaux avantages de React Hook Form par rapport aux solutions React natives :

  1. Rendu minimal - la bibliothèque minimise les re-rendus inutiles, ce qui améliore les performances
  2. Moins de code répétitif - par rapport aux composants contrôlés natifs
  3. Intégration facile - fonctionne avec les composants contrôlés comme non contrôlés
  4. Validation avancée - de nombreuses possibilités de validation, intégration avec Yup, Zod, Joi et d'autres bibliothèques
  5. Typage TypeScript - support complet de TypeScript
  6. Performances - optimisé pour les performances, même dans les formulaires complexes

Les bases de React Hook Form

Commençons par un exemple simple de formulaire de connexion :

1import { useForm, SubmitHandler } from 'react-hook-form';
2
3type LoginInputs = {
4  email: string;
5  password: string;
6  rememberMe: boolean;
7};
8
9export default function LoginForm() {
10  const {
11    register,
12    handleSubmit,
13    formState: { errors, isSubmitting }
14  } = useForm<LoginInputs>();
15
16  const onSubmit: SubmitHandler<LoginInputs> = async (data) => {
17    // Dans une vraie application, nous appellerions l'API ici
18    console.log('Données de connexion :', data);
19
20    // Simuler un délai réseau
21    await new Promise(resolve => setTimeout(resolve, 1000));
22  };
23
24  return (
25    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
26      <div>
27        <label htmlFor="email" className="block text-sm font-medium">
28          Email
29        </label>
30        <input
31          id="email"
32          type="email"
33          className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
34          {...register('email', {
35            required: "L'email est requis",
36            pattern: {
37              value: /^\S+@\S+\.\S+$/,
38              message: "Format d'email invalide"
39            }
40          })}
41        />
42        {errors.email && (
43          <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
44        )}
45      </div>
46
47      <div>
48        <label htmlFor="password" className="block text-sm font-medium">
49          Mot de passe
50        </label>
51        <input
52          id="password"
53          type="password"
54          className={`mt-1 block w-full rounded-md ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
55          {...register('password', {
56            required: 'Le mot de passe est requis',
57            minLength: {
58              value: 6,
59              message: 'Le mot de passe doit contenir au moins 6 caractères'
60            }
61          })}
62        />
63        {errors.password && (
64          <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
65        )}
66      </div>
67
68      <div className="flex items-center">
69        <input
70          id="rememberMe"
71          type="checkbox"
72          className="h-4 w-4 rounded border-gray-300"
73          {...register('rememberMe')}
74        />
75        <label htmlFor="rememberMe" className="ml-2 block text-sm">
76          Se souvenir de moi
77        </label>
78      </div>
79
80      <button
81        type="submit"
82        disabled={isSubmitting}
83        className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
84      >
85        {isSubmitting ? 'Connexion...' : 'Se connecter'}
86      </button>
87    </form>
88  );
89}

Les principaux éléments de React Hook Form dans l'exemple ci-dessus :

  1. useForm - le hook principal qui fournit les méthodes de gestion du formulaire
  2. register - fonction qui enregistre les champs du formulaire dans React Hook Form
  3. handleSubmit - fonction qui enveloppe notre gestionnaire de soumission du formulaire
  4. formState - objet contenant des informations sur l'état du formulaire, y compris les erreurs et le statut de soumission

Validation avancée avec Zod

React Hook Form s'intègre parfaitement avec des bibliothèques de validation comme Zod, permettant de créer des schémas de validation robustes :

1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// Définition du schéma avec Zod
6const signupSchema = z.object({
7  username: z.string()
8    .min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères")
9    .max(20, "Le nom d'utilisateur peut contenir au maximum 20 caractères")
10    .regex(/^[a-zA-Z0-9_]+$/, "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et des tirets bas"),
11  email: z.string()
12    .email('Fournis une adresse email valide'),
13  password: z.string()
14    .min(8, 'Le mot de passe doit contenir au moins 8 caractères')
15    .regex(/[A-Z]/, 'Le mot de passe doit contenir au moins une majuscule')
16    .regex(/[a-z]/, 'Le mot de passe doit contenir au moins une minuscule')
17    .regex(/[0-9]/, 'Le mot de passe doit contenir au moins un chiffre'),
18  confirmPassword: z.string(),
19  terms: z.boolean().refine(val => val === true, {
20    message: 'Tu dois accepter les conditions',
21  }),
22}).refine(data => data.password === data.confirmPassword, {
23  message: 'Les mots de passe ne correspondent pas',
24  path: ['confirmPassword'],
25});
26
27// Type résultant du schéma
28type SignupInputs = z.infer<typeof signupSchema>;
29
30export default function SignupForm() {
31  const {
32    register,
33    handleSubmit,
34    formState: { errors, isSubmitting, isSubmitSuccessful },
35    reset
36  } = useForm<SignupInputs>({
37    resolver: zodResolver(signupSchema),
38    defaultValues: {
39      username: '',
40      email: '',
41      password: '',
42      confirmPassword: '',
43      terms: false
44    }
45  });
46
47  const onSubmit: SubmitHandler<SignupInputs> = async (data) => {
48    try {
49      // Simuler l'envoi des données à l'API
50      await new Promise(resolve => setTimeout(resolve, 1500));
51      console.log("Données d'inscription :", data);
52
53      // Réinitialiser le formulaire après un envoi réussi
54      reset();
55    } catch (error) {
56      console.error("Erreur d'inscription :", error);
57    }
58  };
59
60  return (
61    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
62      <h2 className="text-2xl font-bold mb-6">Inscription</h2>
63
64      {isSubmitSuccessful && (
65        <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
66          Inscription réalisée avec succès !
67        </div>
68      )}
69
70      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
71        <div>
72          <label htmlFor="username" className="block text-sm font-medium">
73            Nom d'utilisateur
74          </label>
75          <input
76            id="username"
77            type="text"
78            className={`mt-1 block w-full rounded-md ${errors.username ? 'border-red-500' : 'border-gray-300'}`}
79            {...register('username')}
80          />
81          {errors.username && (
82            <p className="text-red-500 text-xs mt-1">{errors.username.message}</p>
83          )}
84        </div>
85
86        <div>
87          <label htmlFor="email" className="block text-sm font-medium">
88            Email
89          </label>
90          <input
91            id="email"
92            type="email"
93            className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
94            {...register('email')}
95          />
96          {errors.email && (
97            <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
98          )}
99        </div>
100
101        <div>
102          <label htmlFor="password" className="block text-sm font-medium">
103            Mot de passe
104          </label>
105          <input
106            id="password"
107            type="password"
108            className={`mt-1 block w-full rounded-md ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
109            {...register('password')}
110          />
111          {errors.password && (
112            <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
113          )}
114        </div>
115
116        <div>
117          <label htmlFor="confirmPassword" className="block text-sm font-medium">
118            Confirmer le mot de passe
119          </label>
120          <input
121            id="confirmPassword"
122            type="password"
123            className={`mt-1 block w-full rounded-md ${errors.confirmPassword ? 'border-red-500' : 'border-gray-300'}`}
124            {...register('confirmPassword')}
125          />
126          {errors.confirmPassword && (
127            <p className="text-red-500 text-xs mt-1">{errors.confirmPassword.message}</p>
128          )}
129        </div>
130
131        <div className="flex items-center">
132          <input
133            id="terms"
134            type="checkbox"
135            className={`h-4 w-4 rounded ${errors.terms ? 'border-red-500' : 'border-gray-300'}`}
136            {...register('terms')}
137          />
138          <label htmlFor="terms" className="ml-2 block text-sm">
139            J'accepte les conditions d'utilisation
140          </label>
141        </div>
142        {errors.terms && (
143          <p className="text-red-500 text-xs">{errors.terms.message}</p>
144        )}
145
146        <button
147          type="submit"
148          disabled={isSubmitting}
149          className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
150        >
151          {isSubmitting ? 'Inscription...' : "S'inscrire"}
152        </button>
153      </form>
154    </div>
155  );
156}

Éléments clés de cet exemple :

  1. zodResolver - adaptateur qui intègre Zod à React Hook Form
  2. Règles de validation avancées - expressions régulières complexes, longueur minimale, etc.
  3. fonction refine - pour des conditions de validation personnalisées, comme la comparaison des mots de passe
  4. z.infer<typeof schema> - génération automatique du type depuis la définition du schéma

Formulaires dynamiques et champs conditionnels

React Hook Form gère parfaitement les formulaires dynamiques, dans lesquels les champs peuvent apparaître ou disparaître selon les conditions :

1import { useForm, useWatch, SubmitHandler, Controller } from 'react-hook-form';
2import { useState } from 'react';
3
4type DeliveryMethod = 'standard' | 'express' | 'pickup';
5
6type CheckoutInputs = {
7  firstName: string;
8  lastName: string;
9  email: string;
10  deliveryMethod: DeliveryMethod;
11  address?: string;
12  city?: string;
13  postalCode?: string;
14  pickupLocation?: string;
15};
16
17export default function DynamicCheckoutForm() {
18  const {
19    register,
20    handleSubmit,
21    control,
22    formState: { errors, isSubmitting }
23  } = useForm<CheckoutInputs>({
24    defaultValues: {
25      firstName: '',
26      lastName: '',
27      email: '',
28      deliveryMethod: 'standard',
29    }
30  });
31
32  // Nous utilisons useWatch pour observer les changements du mode de livraison sélectionné
33  const deliveryMethod = useWatch({
34    control,
35    name: 'deliveryMethod',
36    defaultValue: 'standard',
37  });
38
39  // Nous vérifions si une adresse de livraison est nécessaire
40  const needsShippingAddress = deliveryMethod === 'standard' || deliveryMethod === 'express';
41
42  const onSubmit: SubmitHandler<CheckoutInputs> = async (data) => {
43    // Simuler un délai de l'API
44    await new Promise(resolve => setTimeout(resolve, 1000));
45    console.log('Données de commande :', data);
46  };
47
48  return (
49    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
50      <h2 className="text-2xl font-bold mb-6">Validation de la commande</h2>
51
52      <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
53        <div className="grid grid-cols-2 gap-4">
54          <div>
55            <label htmlFor="firstName" className="block text-sm font-medium">
56              Prénom
57            </label>
58            <input
59              id="firstName"
60              type="text"
61              className={`mt-1 block w-full rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
62              {...register('firstName', { required: 'Le prénom est requis' })}
63            />
64            {errors.firstName && (
65              <p className="text-red-500 text-xs mt-1">{errors.firstName.message}</p>
66            )}
67          </div>
68
69          <div>
70            <label htmlFor="lastName" className="block text-sm font-medium">
71              Nom de famille
72            </label>
73            <input
74              id="lastName"
75              type="text"
76              className={`mt-1 block w-full rounded-md ${errors.lastName ? 'border-red-500' : 'border-gray-300'}`}
77              {...register('lastName', { required: 'Le nom de famille est requis' })}
78            />
79            {errors.lastName && (
80              <p className="text-red-500 text-xs mt-1">{errors.lastName.message}</p>
81            )}
82          </div>
83        </div>
84
85        <div>
86          <label htmlFor="email" className="block text-sm font-medium">
87            Email
88          </label>
89          <input
90            id="email"
91            type="email"
92            className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
93            {...register('email', {
94              required: "L'email est requis",
95              pattern: {
96                value: /^\S+@\S+\.\S+$/,
97                message: "Format d'email invalide"
98              }
99            })}
100          />
101          {errors.email && (
102            <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
103          )}
104        </div>
105
106        <div>
107          <label className="block text-sm font-medium mb-2">
108            Mode de livraison
109          </label>
110
111          <div className="space-y-2">
112            <label className="flex items-center">
113              <input
114                type="radio"
115                value="standard"
116                className="mr-2"
117                {...register('deliveryMethod')}
118              />
119              Livraison standard (2-3 jours ouvrés)
120            </label>
121
122            <label className="flex items-center">
123              <input
124                type="radio"
125                value="express"
126                className="mr-2"
127                {...register('deliveryMethod')}
128              />
129              Livraison express (24h)
130            </label>
131
132            <label className="flex items-center">
133              <input
134                type="radio"
135                value="pickup"
136                className="mr-2"
137                {...register('deliveryMethod')}
138              />
139              Retrait en personne
140            </label>
141          </div>
142        </div>
143
144        {needsShippingAddress && (
145          <div className="space-y-4 p-4 bg-gray-50 rounded">
146            <h3 className="font-medium">Adresse de livraison</h3>
147
148            <div>
149              <label htmlFor="address" className="block text-sm font-medium">
150                Adresse
151              </label>
152              <input
153                id="address"
154                type="text"
155                className={`mt-1 block w-full rounded-md ${errors.address ? 'border-red-500' : 'border-gray-300'}`}
156                {...register('address', { required: "L'adresse est requise" })}
157              />
158              {errors.address && (
159                <p className="text-red-500 text-xs mt-1">{errors.address.message}</p>
160              )}
161            </div>
162
163            <div className="grid grid-cols-2 gap-4">
164              <div>
165                <label htmlFor="city" className="block text-sm font-medium">
166                  Ville
167                </label>
168                <input
169                  id="city"
170                  type="text"
171                  className={`mt-1 block w-full rounded-md ${errors.city ? 'border-red-500' : 'border-gray-300'}`}
172                  {...register('city', { required: 'La ville est requise' })}
173                />
174                {errors.city && (
175                  <p className="text-red-500 text-xs mt-1">{errors.city.message}</p>
176                )}
177              </div>
178
179              <div>
180                <label htmlFor="postalCode" className="block text-sm font-medium">
181                  Code postal
182                </label>
183                <input
184                  id="postalCode"
185                  type="text"
186                  className={`mt-1 block w-full rounded-md ${errors.postalCode ? 'border-red-500' : 'border-gray-300'}`}
187                  {...register('postalCode', {
188                    required: 'Le code postal est requis',
189                    pattern: {
190                      value: /^\d{2}-\d{3}$/,
191                      message: 'Format: 00-000'
192                    }
193                  })}
194                />
195                {errors.postalCode && (
196                  <p className="text-red-500 text-xs mt-1">{errors.postalCode.message}</p>
197                )}
198              </div>
199            </div>
200          </div>
201        )}
202
203        {deliveryMethod === 'pickup' && (
204          <div>
205            <label htmlFor="pickupLocation" className="block text-sm font-medium">
206              Point de retrait
207            </label>
208            <select
209              id="pickupLocation"
210              className={`mt-1 block w-full rounded-md ${errors.pickupLocation ? 'border-red-500' : 'border-gray-300'}`}
211              {...register('pickupLocation', { required: 'Sélectionne un point de retrait' })}
212            >
213              <option value="">Sélectionne un point de retrait</option>
214              <option value="warszawa-centrum">Varsovie - Centre</option>
215              <option value="warszawa-mokotow">Varsovie - Mokotów</option>
216              <option value="krakow-centrum">Cracovie - Centre</option>
217              <option value="paris-centre">Paris - Centre</option>
218            </select>
219            {errors.pickupLocation && (
220              <p className="text-red-500 text-xs mt-1">{errors.pickupLocation.message}</p>
221            )}
222          </div>
223        )}
224
225        <button
226          type="submit"
227          disabled={isSubmitting}
228          className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
229        >
230          {isSubmitting ? 'Traitement...' : 'Passer la commande'}
231        </button>
232      </form>
233    </div>
234  );
235}

Éléments clés d'un formulaire dynamique :

  1. useWatch - permet d'observer les changements de certains champs sans provoquer le re-rendu de tout le formulaire
  2. Sections rendues conditionnellement - selon le mode de livraison sélectionné
  3. Validation conditionnelle - des règles de validation différentes pour différentes sections du formulaire

Formulaires multi-étapes

React Hook Form fonctionne aussi très bien dans les formulaires multi-étapes :

1import { useForm, SubmitHandler, FormProvider, useFormContext } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4import { useState } from 'react';
5
6// Schéma des données personnelles
7const personalInfoSchema = z.object({
8  firstName: z.string().min(1, 'Le prénom est requis'),
9  lastName: z.string().min(1, 'Le nom de famille est requis'),
10  email: z.string().email("Format d'email invalide"),
11  phoneNumber: z.string().regex(/^[0-9]{9}$/, 'Le numéro de téléphone doit comporter 9 chiffres')
12});
13
14// Schéma de l'adresse
15const addressSchema = z.object({
16  street: z.string().min(1, 'La rue est requise'),
17  city: z.string().min(1, 'La ville est requise'),
18  postalCode: z.string().regex(/^\d{2}-\d{3}$/, 'Le code postal doit être au format 00-000'),
19  country: z.string().min(1, 'Le pays est requis')
20});
21
22// Schéma du paiement
23const paymentSchema = z.object({
24  cardName: z.string().min(1, 'Le nom sur la carte est requis'),
25  cardNumber: z.string().regex(/^[0-9]{16}$/, 'Le numéro de carte doit comporter 16 chiffres'),
26  expiryDate: z.string().regex(/^(0[1-9]|1[0-2])\/[0-9]{2}$/, 'Format : MM/AA'),
27  cvv: z.string().regex(/^[0-9]{3,4}$/, 'Le CVV doit comporter 3 ou 4 chiffres')
28});
29
30// Schéma combiné
31const formSchema = z.object({
32  personalInfo: personalInfoSchema,
33  address: addressSchema,
34  payment: paymentSchema
35});
36
37type MultiStepFormData = z.infer<typeof formSchema>;
38
39// Composant de l'étape des données personnelles
40function PersonalInfoStep() {
41  const { register, formState: { errors } } = useFormContext<MultiStepFormData>();
42
43  return (
44    <div className="space-y-4">
45      <h2 className="text-xl font-bold">Données personnelles</h2>
46
47      <div className="grid grid-cols-2 gap-4">
48        <div>
49          <label htmlFor="firstName" className="block text-sm font-medium">
50            Prénom
51          </label>
52          <input
53            id="firstName"
54            type="text"
55            className={`mt-1 block w-full rounded-md ${
56              errors.personalInfo?.firstName ? 'border-red-500' : 'border-gray-300'
57            }`}
58            {...register('personalInfo.firstName')}
59          />
60          {errors.personalInfo?.firstName && (
61            <p className="text-red-500 text-xs mt-1">{errors.personalInfo.firstName.message}</p>
62          )}
63        </div>
64
65        <div>
66          <label htmlFor="lastName" className="block text-sm font-medium">
67            Nom de famille
68          </label>
69          <input
70            id="lastName"
71            type="text"
72            className={`mt-1 block w-full rounded-md ${
73              errors.personalInfo?.lastName ? 'border-red-500' : 'border-gray-300'
74            }`}
75            {...register('personalInfo.lastName')}
76          />
77          {errors.personalInfo?.lastName && (
78            <p className="text-red-500 text-xs mt-1">{errors.personalInfo.lastName.message}</p>
79          )}
80        </div>
81      </div>
82
83      <div>
84        <label htmlFor="email" className="block text-sm font-medium">
85          Email
86        </label>
87        <input
88          id="email"
89          type="email"
90          className={`mt-1 block w-full rounded-md ${
91            errors.personalInfo?.email ? 'border-red-500' : 'border-gray-300'
92          }`}
93          {...register('personalInfo.email')}
94        />
95        {errors.personalInfo?.email && (
96          <p className="text-red-500 text-xs mt-1">{errors.personalInfo.email.message}</p>
97        )}
98      </div>
99
100      <div>
101        <label htmlFor="phoneNumber" className="block text-sm font-medium">
102          Numéro de téléphone
103        </label>
104        <input
105          id="phoneNumber"
106          type="tel"
107          className={`mt-1 block w-full rounded-md ${
108            errors.personalInfo?.phoneNumber ? 'border-red-500' : 'border-gray-300'
109          }`}
110          {...register('personalInfo.phoneNumber')}
111        />
112        {errors.personalInfo?.phoneNumber && (
113          <p className="text-red-500 text-xs mt-1">{errors.personalInfo.phoneNumber.message}</p>
114        )}
115      </div>
116    </div>
117  );
118}
119
120// Composant de l'étape de l'adresse
121function AddressStep() {
122  const { register, formState: { errors } } = useFormContext<MultiStepFormData>();
123
124  return (
125    <div className="space-y-4">
126      <h2 className="text-xl font-bold">Adresse de livraison</h2>
127
128      <div>
129        <label htmlFor="street" className="block text-sm font-medium">
130          Rue et numéro
131        </label>
132        <input
133          id="street"
134          type="text"
135          className={`mt-1 block w-full rounded-md ${
136            errors.address?.street ? 'border-red-500' : 'border-gray-300'
137          }`}
138          {...register('address.street')}
139        />
140        {errors.address?.street && (
141          <p className="text-red-500 text-xs mt-1">{errors.address.street.message}</p>
142        )}
143      </div>
144
145      <div className="grid grid-cols-2 gap-4">
146        <div>
147          <label htmlFor="city" className="block text-sm font-medium">
148            Ville
149          </label>
150          <input
151            id="city"
152            type="text"
153            className={`mt-1 block w-full rounded-md ${
154              errors.address?.city ? 'border-red-500' : 'border-gray-300'
155            }`}
156            {...register('address.city')}
157          />
158          {errors.address?.city && (
159            <p className="text-red-500 text-xs mt-1">{errors.address.city.message}</p>
160          )}
161        </div>
162
163        <div>
164          <label htmlFor="postalCode" className="block text-sm font-medium">
165            Code postal
166          </label>
167          <input
168            id="postalCode"
169            type="text"
170            className={`mt-1 block w-full rounded-md ${
171              errors.address?.postalCode ? 'border-red-500' : 'border-gray-300'
172            }`}
173            {...register('address.postalCode')}
174          />
175          {errors.address?.postalCode && (
176            <p className="text-red-500 text-xs mt-1">{errors.address.postalCode.message}</p>
177          )}
178        </div>
179      </div>
180
181      <div>
182        <label htmlFor="country" className="block text-sm font-medium">
183          Pays
184        </label>
185        <select
186          id="country"
187          className={`mt-1 block w-full rounded-md ${
188            errors.address?.country ? 'border-red-500' : 'border-gray-300'
189          }`}
190          {...register('address.country')}
191        >
192          <option value="">Sélectionne un pays</option>
193          <option value="Poland">Pologne</option>
194          <option value="Germany">Allemagne</option>
195          <option value="France">France</option>
196          <option value="UK">Royaume-Uni</option>
197        </select>
198        {errors.address?.country && (
199          <p className="text-red-500 text-xs mt-1">{errors.address.country.message}</p>
200        )}
201      </div>
202    </div>
203  );
204}
205
206// Composant de l'étape du paiement
207function PaymentStep() {
208  const { register, formState: { errors } } = useFormContext<MultiStepFormData>();
209
210  return (
211    <div className="space-y-4">
212      <h2 className="text-xl font-bold">Mode de paiement</h2>
213
214      <div>
215        <label htmlFor="cardName" className="block text-sm font-medium">
216          Nom sur la carte
217        </label>
218        <input
219          id="cardName"
220          type="text"
221          className={`mt-1 block w-full rounded-md ${
222            errors.payment?.cardName ? 'border-red-500' : 'border-gray-300'
223          }`}
224          {...register('payment.cardName')}
225        />
226        {errors.payment?.cardName && (
227          <p className="text-red-500 text-xs mt-1">{errors.payment.cardName.message}</p>
228        )}
229      </div>
230
231      <div>
232        <label htmlFor="cardNumber" className="block text-sm font-medium">
233          Numéro de carte
234        </label>
235        <input
236          id="cardNumber"
237          type="text"
238          className={`mt-1 block w-full rounded-md ${
239            errors.payment?.cardNumber ? 'border-red-500' : 'border-gray-300'
240          }`}
241          {...register('payment.cardNumber')}
242        />
243        {errors.payment?.cardNumber && (
244          <p className="text-red-500 text-xs mt-1">{errors.payment.cardNumber.message}</p>
245        )}
246      </div>
247
248      <div className="grid grid-cols-2 gap-4">
249        <div>
250          <label htmlFor="expiryDate" className="block text-sm font-medium">
251            Date d'expiration
252          </label>
253          <input
254            id="expiryDate"
255            type="text"
256            className={`mt-1 block w-full rounded-md ${
257              errors.payment?.expiryDate ? 'border-red-500' : 'border-gray-300'
258            }`}
259            {...register('payment.expiryDate')}
260          />
261          {errors.payment?.expiryDate && (
262            <p className="text-red-500 text-xs mt-1">{errors.payment.expiryDate.message}</p>
263          )}
264        </div>
265
266        <div>
267          <label htmlFor="cvv" className="block text-sm font-medium">
268            CVV
269          </label>
270          <input
271            id="cvv"
272            type="text"
273            className={`mt-1 block w-full rounded-md ${
274              errors.payment?.cvv ? 'border-red-500' : 'border-gray-300'
275            }`}
276            {...register('payment.cvv')}
277          />
278          {errors.payment?.cvv && (
279            <p className="text-red-500 text-xs mt-1">{errors.payment.cvv.message}</p>
280          )}
281        </div>
282      </div>
283    </div>
284  );
285}
286
287// Composant de récapitulatif
288function SummaryStep({ data }: { data: MultiStepFormData }) {
289  return (
290    <div className="space-y-6">
291      <h2 className="text-xl font-bold">Récapitulatif de la commande</h2>
292
293      <div className="bg-gray-50 p-4 rounded">
294        <h3 className="font-medium mb-2">Données personnelles</h3>
295        <p>{data.personalInfo.firstName} {data.personalInfo.lastName}</p>
296        <p>{data.personalInfo.email}</p>
297        <p>Téléphone : {data.personalInfo.phoneNumber}</p>
298      </div>
299
300      <div className="bg-gray-50 p-4 rounded">
301        <h3 className="font-medium mb-2">Adresse de livraison</h3>
302        <p>{data.address.street}</p>
303        <p>{data.address.postalCode} {data.address.city}</p>
304        <p>{data.address.country}</p>
305      </div>
306
307      <div className="bg-gray-50 p-4 rounded">
308        <h3 className="font-medium mb-2">Mode de paiement</h3>
309        <p>{data.payment.cardName}</p>
310        <p>Carte : **** **** **** {data.payment.cardNumber.slice(-4)}</p>
311        <p>Valable jusqu'au : {data.payment.expiryDate}</p>
312      </div>
313    </div>
314  );
315}
316
317export default function MultiStepForm() {
318  const [currentStep, setCurrentStep] = useState(0);
319  const [isSubmitted, setIsSubmitted] = useState(false);
320
321  const methods = useForm<MultiStepFormData>({
322    resolver: zodResolver(formSchema),
323    mode: 'onBlur',
324    defaultValues: {
325      personalInfo: {
326        firstName: '',
327        lastName: '',
328        email: '',
329        phoneNumber: ''
330      },
331      address: {
332        street: '',
333        city: '',
334        postalCode: '',
335        country: ''
336      },
337      payment: {
338        cardName: '',
339        cardNumber: '',
340        expiryDate: '',
341        cvv: ''
342      }
343    }
344  });
345
346  const steps = [
347    { name: 'Données personnelles', component: <PersonalInfoStep /> },
348    { name: 'Adresse', component: <AddressStep /> },
349    { name: 'Paiement', component: <PaymentStep /> },
350    { name: 'Récapitulatif', component: <SummaryStep data={methods.getValues()} /> }
351  ];
352
353  const onSubmit: SubmitHandler<MultiStepFormData> = async (data) => {
354    // Traitement final de la soumission des données
355    if (currentStep === steps.length - 1) {
356      await new Promise(resolve => setTimeout(resolve, 1500));
357      console.log('Commande passée :', data);
358      setIsSubmitted(true);
359    } else {
360      // Passer à l'étape suivante
361      setCurrentStep(prev => prev + 1);
362    }
363  };
364
365  const handlePrevStep = () => {
366    setCurrentStep(prev => Math.max(0, prev - 1));
367  };
368
369  const validateCurrentStep = async () => {
370    let isValid = false;
371
372    switch (currentStep) {
373      case 0:
374        isValid = await methods.trigger('personalInfo');
375        break;
376      case 1:
377        isValid = await methods.trigger('address');
378        break;
379      case 2:
380        isValid = await methods.trigger('payment');
381        break;
382      case 3:
383        isValid = true; // Le récapitulatif ne nécessite pas de validation
384        break;
385    }
386
387    return isValid;
388  };
389
390  const handleNextStep = async () => {
391    const isValid = await validateCurrentStep();
392
393    if (isValid) {
394      setCurrentStep(prev => Math.min(steps.length - 1, prev + 1));
395    }
396  };
397
398  return (
399    <div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-md">
400      {isSubmitted ? (
401        <div className="text-center py-8">
402          <svg className="w-16 h-16 text-green-500 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
403            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
404          </svg>
405          <h2 className="text-2xl font-bold mb-2">Commande acceptée !</h2>
406          <p className="text-gray-600 mb-4">Merci d'avoir passé ta commande. Nous avons envoyé les détails à l'adresse email fournie.</p>
407          <button
408            onClick={() => {
409              methods.reset();
410              setCurrentStep(0);
411              setIsSubmitted(false);
412            }}
413            className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
414          >
415            Passer une nouvelle commande
416          </button>
417        </div>
418      ) : (
419        <>
420          {/* Barre de progression */}
421          <div className="mb-8">
422            <div className="flex justify-between mb-2">
423              {steps.map((step, index) => (
424                <div key={index} className="flex flex-col items-center">
425                  <div
426                    className={`w-8 h-8 flex items-center justify-center rounded-full ${index <= currentStep ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}
427                  >
428                    {index + 1}
429                  </div>
430                  <span className="text-xs mt-1">{step.name}</span>
431                </div>
432              ))}
433            </div>
434            <div className="relative w-full h-2 bg-gray-200 rounded-full overflow-hidden">
435              <div
436                className="absolute h-full bg-blue-600 transition-all duration-300"
437                style={{ width: `${(currentStep / (steps.length - 1)) * 100}%` }}
438              ></div>
439            </div>
440          </div>
441
442          <FormProvider {...methods}>
443            <form onSubmit={methods.handleSubmit(onSubmit)} className="space-y-8">
444              {steps[currentStep].component}
445
446              <div className="flex justify-between mt-8">
447                <button
448                  type="button"
449                  onClick={handlePrevStep}
450                  disabled={currentStep === 0}
451                  className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
452                >
453                  Retour
454                </button>
455
456                {currentStep < steps.length - 1 ? (
457                  <button
458                    type="button"
459                    onClick={handleNextStep}
460                    className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
461                  >
462                    Suivant
463                  </button>
464                ) : (
465                  <button
466                    type="submit"
467                    className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
468                  >
469                    {methods.formState.isSubmitting ? 'Traitement...' : 'Passer la commande'}
470                  </button>
471                )}
472              </div>
473            </form>
474          </FormProvider>
475        </>
476      )}
477    </div>
478  );
479}

Principaux éléments d'un formulaire multi-étapes :

  1. FormProvider - fournit le contexte du formulaire aux composants imbriqués
  2. useFormContext - permet aux composants enfants d'accéder aux méthodes du formulaire
  3. Champs imbriqués - via la notation par points (par ex. personalInfo.firstName)
  4. Validation étape par étape - via la méthode trigger pour valider des parties spécifiques du formulaire

Intégration avec les Server Actions en Next.js

React Hook Form peut facilement être combiné avec les Server Actions en Next.js :

1// app/newsletter/page.tsx
2'use client';
3
4import { useForm, SubmitHandler } from 'react-hook-form';
5import { zodResolver } from '@hookform/resolvers/zod';
6import { z } from 'zod';
7import { useState } from 'react';
8import { subscribeToNewsletter } from './actions';
9
10const newsletterSchema = z.object({
11  email: z.string().email('Fournis une adresse email valide'),
12  firstName: z.string().min(1, 'Le prénom est requis'),
13  preferences: z.object({
14    marketing: z.boolean().optional(),
15    updates: z.boolean().optional(),
16    news: z.boolean().optional()
17  }).refine(prefs => prefs.marketing || prefs.updates || prefs.news, {
18    message: 'Sélectionne au moins une préférence',
19    path: ['_errors']
20  })
21});
22
23type NewsletterFormInputs = z.infer<typeof newsletterSchema>;
24
25export default function NewsletterForm() {
26  const [submitStatus, setSubmitStatus] = useState<{ success: boolean; message: string } | null>(null);
27
28  const {
29    register,
30    handleSubmit,
31    formState: { errors, isSubmitting },
32    reset
33  } = useForm<NewsletterFormInputs>({
34    resolver: zodResolver(newsletterSchema),
35    defaultValues: {
36      email: '',
37      firstName: '',
38      preferences: {
39        marketing: false,
40        updates: false,
41        news: false
42      }
43    }
44  });
45
46  const onSubmit: SubmitHandler<NewsletterFormInputs> = async (data) => {
47    try {
48      // Appeler la Server Action
49      const result = await subscribeToNewsletter(data);
50
51      setSubmitStatus({
52        success: result.success,
53        message: result.message
54      });
55
56      if (result.success) {
57        reset(); // Réinitialiser le formulaire après un envoi réussi
58      }
59    } catch (error) {
60      setSubmitStatus({
61        success: false,
62        message: "Une erreur inattendue s'est produite. Réessaie plus tard."
63      });
64    }
65  };
66
67  return (
68    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
69      <h2 className="text-2xl font-bold mb-6">S'abonner à la newsletter</h2>
70
71      {submitStatus && (
72        <div className={`mb-4 p-3 rounded ${submitStatus.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
73          {submitStatus.message}
74        </div>
75      )}
76
77      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
78        <div>
79          <label htmlFor="email" className="block text-sm font-medium">
80            Email
81          </label>
82          <input
83            id="email"
84            type="email"
85            className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
86            {...register('email')}
87          />
88          {errors.email && (
89            <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
90          )}
91        </div>
92
93        <div>
94          <label htmlFor="firstName" className="block text-sm font-medium">
95            Prénom
96          </label>
97          <input
98            id="firstName"
99            type="text"
100            className={`mt-1 block w-full rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
101            {...register('firstName')}
102          />
103          {errors.firstName && (
104            <p className="text-red-500 text-xs mt-1">{errors.firstName.message}</p>
105          )}
106        </div>
107
108        <div>
109          <label className="block text-sm font-medium mb-2">
110            Préférences de newsletter
111          </label>
112
113          <div className="space-y-2">
114            <label className="flex items-center">
115              <input
116                type="checkbox"
117                className="h-4 w-4 rounded border-gray-300 mr-2"
118                {...register('preferences.marketing')}
119              />
120              Informations marketing et promotions
121            </label>
122
123            <label className="flex items-center">
124              <input
125                type="checkbox"
126                className="h-4 w-4 rounded border-gray-300 mr-2"
127                {...register('preferences.updates')}
128              />
129              Mises à jour des produits et services
130            </label>
131
132            <label className="flex items-center">
133              <input
134                type="checkbox"
135                className="h-4 w-4 rounded border-gray-300 mr-2"
136                {...register('preferences.news')}
137              />
138              Actualités du secteur
139            </label>
140          </div>
141
142          {errors.preferences?.['_errors'] && (
143            <p className="text-red-500 text-xs mt-1">{errors.preferences['_errors'].join(', ')}</p>
144          )}
145        </div>
146
147        <button
148          type="submit"
149          disabled={isSubmitting}
150          className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
151        >
152          {isSubmitting ? 'Envoi...' : "S'abonner"}
153        </button>
154      </form>
155    </div>
156  );
157}

Et le fichier de la Server Action :

1// app/newsletter/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6const newsletterSchema = z.object({
7  email: z.string().email(),
8  firstName: z.string().min(1),
9  preferences: z.object({
10    marketing: z.boolean().optional(),
11    updates: z.boolean().optional(),
12    news: z.boolean().optional()
13  })
14});
15
16type NewsletterData = z.infer<typeof newsletterSchema>;
17
18export async function subscribeToNewsletter(data: NewsletterData) {
19  // Valider les données sur le serveur pour la sécurité
20  const validationResult = newsletterSchema.safeParse(data);
21
22  if (!validationResult.success) {
23    return {
24      success: false,
25      message: 'Données invalides. Vérifie le formulaire et réessaie.'
26    };
27  }
28
29  try {
30    // Simuler un délai de l'API
31    await new Promise(resolve => setTimeout(resolve, 1000));
32
33    // Ici se trouverait le vrai code pour abonner l'utilisateur à la newsletter
34    // par ex. appel d'API, sauvegarde en base de données, etc.
35    console.log('Abonné à la newsletter :', validationResult.data);
36
37    return {
38      success: true,
39      message: "Merci de t'être abonné à la newsletter !"
40    };
41  } catch (error) {
42    console.error("Erreur lors de l'abonnement à la newsletter :", error);
43    return {
44      success: false,
45      message: "Une erreur s'est produite lors du traitement. Réessaie plus tard."
46    };
47  }
48}

Principaux éléments de l'intégration de React Hook Form avec les Server Actions :

  1. Typage partagé - le même type de données est utilisé côté client et côté serveur
  2. Validation bidirectionnelle - les données sont validées à la fois par React Hook Form et sur le serveur
  3. Gestion des erreurs - gestion complète des erreurs des deux côtés

Bonnes pratiques et astuces

  1. Utilise TypeScript - il fournit un typage statique et permet de détecter les erreurs à la compilation

  2. Découpe les composants - divise les grands formulaires en composants plus petits et gérables

  3. Utilise mode: 'onBlur' - valide les formulaires en quittant un champ plutôt qu'à chaque changement :

    1const { register } = useForm({ mode: 'onBlur' });
  4. Utilise shouldUnregister - pour les champs de formulaire dynamiques qui peuvent apparaître et disparaître :

    1const { register } = useForm({ shouldUnregister: true });
  5. Utilise setValue pour les cas complexes - pour définir des valeurs par programmation :

    1setValue('field', value, { shouldValidate: true, shouldDirty: true });
  6. Utilise transform pour formater les données :

    1register('amount', {
    2  setValueAs: value => parseFloat(value) || 0
    3});

Résumé

React Hook Form est un outil puissant qui simplifie considérablement la création et la gestion des formulaires en React. Principaux avantages :

  • Performances - re-rendus minimaux des composants
  • Moins de code - moins de code répétitif que les composants contrôlés natifs
  • Flexibilité - fonctionne avec les composants contrôlés comme non contrôlés
  • Validation avancée - surtout combinée à des bibliothèques comme Zod ou Yup
  • TypeScript - support complet du typage

En connectant React Hook Form aux Server Actions de Next.js, tu peux construire des formulaires efficaces, sécurisés et conviviaux qui se comportent bien avec ou sans JavaScript activé (grâce à l'amélioration progressive de Next.js).

Une bonne gestion des formulaires est essentielle pour construire des applications web de haute qualité, et React Hook Form rend ce processus nettement plus simple et plus agréable, tant pour les développeurs que pour les utilisateurs finaux.

Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Simulation de React Hook Form - useForm, register, handleSubmit
4// (sans bibliotheque externe, pour fonctionner dans sandpack)
5
6type FormValues = { username: string; email: string; age: string; bio: string };
7type Errors = Partial<Record<keyof FormValues, string>>;
8
9// Implementation simplifiee de useForm
10function useForm() {
11  const [values, setValues] = useState<FormValues>({ username: '', email: '', age: '', bio: '' });
12  const [errors, setErrors] = useState<Errors>({});
13  const [touched, setTouched] = useState<Set<string>>(new Set());
14
15  const register = (name: keyof FormValues) => ({
16    value: values[name],
17    onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
18      setValues(prev => ({ ...prev, [name]: e.target.value }));
19      if (touched.has(name)) validate({ ...values, [name]: e.target.value });
20    },
21    onBlur: () => {
22      setTouched(prev => new Set(prev).add(name));
23      validate(values);
24    },
25  });
26
27  const validate = (vals: FormValues): boolean => {
28    const errs: Errors = {};
29    if (!vals.username) errs.username = 'Le nom d\'utilisateur est requis';
30    if (!vals.email) errs.email = 'L\'email est requis';
31    else if (!/\S+@\S+\.\S+/.test(vals.email)) errs.email = 'Format d\'email invalide';
32    if (vals.age && (isNaN(Number(vals.age)) || Number(vals.age) < 1)) errs.age = 'Saisis un age valide';
33    setErrors(errs);
34    return Object.keys(errs).length === 0;
35  };
36
37  const handleSubmit = (onValid: (data: FormValues) => void) => (e: React.FormEvent) => {
38    e.preventDefault();
39    setTouched(new Set(Object.keys(values)));
40    if (validate(values)) onValid(values);
41  };
42
43  return { register, handleSubmit, errors, values };
44}
45
46export default function ReactHookFormDemo() {
47  const { register, handleSubmit, errors, values } = useForm();
48  const [submitted, setSubmitted] = useState<FormValues | null>(null);
49
50  const onSubmit = (data: FormValues) => setSubmitted(data);
51
52  const inputStyle: React.CSSProperties = {
53    width: '100%', padding: '10px', background: '#0d1117',
54    border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
55    fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
56  };
57
58  const errorStyle: React.CSSProperties = { color: '#f44336', fontSize: 11, marginTop: 4 };
59
60  return (
61    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
62      <h1 style={{ color: '#64ffda' }}>React Hook Form</h1>
63      <p style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
64        useForm + register + handleSubmit - re-rendus minimaux, validation facile.
65      </p>
66
67      <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 400 }}>
68        <div style={{ marginBottom: 12 }}>
69          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
70            Username * <code style={{ color: '#555' }}>...register('username')</code>
71          </label>
72          <input {...register('username')} style={{ ...inputStyle, borderColor: errors.username ? '#f44336' : '#333' }} />
73          {errors.username && <div style={errorStyle}>{errors.username}</div>}
74        </div>
75
76        <div style={{ marginBottom: 12 }}>
77          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
78            Email * <code style={{ color: '#555' }}>...register('email')</code>
79          </label>
80          <input {...register('email')} style={{ ...inputStyle, borderColor: errors.email ? '#f44336' : '#333' }} />
81          {errors.email && <div style={errorStyle}>{errors.email}</div>}
82        </div>
83
84        <div style={{ marginBottom: 12 }}>
85          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Age</label>
86          <input {...register('age')} style={{ ...inputStyle, borderColor: errors.age ? '#f44336' : '#333' }} />
87          {errors.age && <div style={errorStyle}>{errors.age}</div>}
88        </div>
89
90        <div style={{ marginBottom: 12 }}>
91          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Bio</label>
92          <textarea {...register('bio')} rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
93        </div>
94
95        <button type="submit" style={{
96          background: '#9c27b0', color: '#fff', border: 'none', borderRadius: 6,
97          padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
98        }}>
99          handleSubmit(onSubmit)
100        </button>
101      </form>
102
103      {submitted && (
104        <div style={{ marginTop: 16, background: '#1b5e20', borderRadius: 8, padding: 12, maxWidth: 400, border: '1px solid #4caf50' }}>
105          <div style={{ color: '#4caf50', marginBottom: 4 }}>Donnees validees et envoyees !</div>
106          <pre style={{ color: '#ccc', fontSize: 11, margin: 0 }}>{JSON.stringify(submitted, null, 2)}</pre>
107        </div>
108      )}
109    </div>
110  );
111}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. React Hook Form est :

Tâches pratiques dans le jeu

  • Éditeur de code

    Construis une interface avec des formulaires interactifs

  • Tri horizontal

    Ordonne la syntaxe d'un élément form avec onSubmit en React

  • Clique dans l'ordre

    Ordonne les éléments dans le bon ordre : const → { → register,

  • Éditeur de code

    Crée un formulaire de connexion avec validation des champs e-mail et mot de passe

Articles utiles