Cours Next.js · Module 6: Formulaires et Server Actions
React Hook Form et la gestion de l'état des formulaires
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 :
- Rendu minimal - la bibliothèque minimise les re-rendus inutiles, ce qui améliore les performances
- Moins de code répétitif - par rapport aux composants contrôlés natifs
- Intégration facile - fonctionne avec les composants contrôlés comme non contrôlés
- Validation avancée - de nombreuses possibilités de validation, intégration avec Yup, Zod, Joi et d'autres bibliothèques
- Typage TypeScript - support complet de TypeScript
- 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 :
- useForm - le hook principal qui fournit les méthodes de gestion du formulaire
- register - fonction qui enregistre les champs du formulaire dans React Hook Form
- handleSubmit - fonction qui enveloppe notre gestionnaire de soumission du formulaire
- 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 :
- zodResolver - adaptateur qui intègre Zod à React Hook Form
- Règles de validation avancées - expressions régulières complexes, longueur minimale, etc.
- fonction refine - pour des conditions de validation personnalisées, comme la comparaison des mots de passe
- 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 :
- useWatch - permet d'observer les changements de certains champs sans provoquer le re-rendu de tout le formulaire
- Sections rendues conditionnellement - selon le mode de livraison sélectionné
- 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 :
- FormProvider - fournit le contexte du formulaire aux composants imbriqués
- useFormContext - permet aux composants enfants d'accéder aux méthodes du formulaire
- Champs imbriqués - via la notation par points (par ex.
personalInfo.firstName) - Validation étape par étape - via la méthode
triggerpour 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 :
- Typage partagé - le même type de données est utilisé côté client et côté serveur
- Validation bidirectionnelle - les données sont validées à la fois par React Hook Form et sur le serveur
- Gestion des erreurs - gestion complète des erreurs des deux côtés
Bonnes pratiques et astuces
Utilise TypeScript - il fournit un typage statique et permet de détecter les erreurs à la compilation
Découpe les composants - divise les grands formulaires en composants plus petits et gérables
Utilise mode: 'onBlur' - valide les formulaires en quittant un champ plutôt qu'à chaque changement :
1const { register } = useForm({ mode: 'onBlur' });Utilise shouldUnregister - pour les champs de formulaire dynamiques qui peuvent apparaître et disparaître :
1const { register } = useForm({ shouldUnregister: true });Utilise setValue pour les cas complexes - pour définir des valeurs par programmation :
1setValue('field', value, { shouldValidate: true, shouldDirty: true });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. 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