Cours Next.js · Module 6: Formulaires et Server Actions
Gestion des erreurs et messages dans les formulaires
Dans cette leçon8
Une gestion efficace des erreurs de formulaire est un élément clé de la construction d'applications web conviviales. Des messages d'erreur bien conçus n'informent pas seulement l'utilisateur des problèmes, ils indiquent aussi comment les résoudre. Dans ce module, nous verrons les bonnes pratiques de gestion des erreurs dans les formulaires React et Next.js, de la conception de messages conviviaux aux techniques de validation avancées.
Types d'erreurs dans les formulaires
Avant de plonger dans les détails d'implémentation, il est utile de comprendre les différents types d'erreurs qui peuvent survenir dans les formulaires :
- Erreurs de validation - liées à des données incorrectes saisies par l'utilisateur (par ex. format d'email invalide)
- Erreurs serveur - problèmes lors du traitement des données côté serveur (par ex. erreur de base de données)
- Erreurs réseau - problèmes de connexion (par ex. délai dépassé, hors ligne)
- Erreurs métier - les données sont techniquement correctes mais violent des règles métier (par ex. adresse email en double)
- Erreurs d'accessibilité - le formulaire est difficile ou impossible à utiliser pour les personnes en situation de handicap
Concevoir des messages d'erreur conviviaux
Des messages d'erreur bien conçus doivent répondre aux critères suivants :
1. Être clairs et précis
Mauvais : « Erreur de validation » Bon : « Le mot de passe doit contenir au moins 8 caractères, dont un chiffre et une majuscule »
2. Utiliser un langage convivial
Mauvais : « Une erreur s'est produite lors de l'analyse du JSON : token inattendu à la position 42 » Bon : « Un problème technique est survenu. Réessaie ou contacte-nous. »
3. Suggérer une solution
Mauvais : « Format de numéro de téléphone invalide » Bon : « Saisis un numéro de téléphone au format 123-456-789 »
4. Être visibles au bon endroit
Les messages d'erreur doivent s'afficher près du champ problématique, et pas seulement en haut du formulaire.
5. Avoir un contraste et une accessibilité appropriés
Les messages d'erreur doivent être clairement visibles et accessibles à tous les utilisateurs, y compris ceux qui utilisent des lecteurs d'écran.
Implémenter la gestion des erreurs dans les formulaires React
Passons à des exemples pratiques d'implémentation de la gestion des erreurs selon différentes approches de formulaire.
Formulaires React natifs
Dans les formulaires React natifs, nous pouvons gérer les erreurs à l'aide de l'état :
1import { useState, FormEvent, ChangeEvent } from 'react';
2
3type FormErrors = {
4 name?: string;
5 email?: string;
6 password?: string;
7 generalError?: string;
8};
9
10export default function SignupForm() {
11 const [formData, setFormData] = useState({
12 name: '',
13 email: '',
14 password: ''
15 });
16
17 const [errors, setErrors] = useState<FormErrors>({});
18 const [isSubmitting, setIsSubmitting] = useState(false);
19 const [submitSuccess, setSubmitSuccess] = useState(false);
20
21 const validateForm = (): boolean => {
22 const newErrors: FormErrors = {};
23 let isValid = true;
24
25 // Validation du prénom
26 if (!formData.name.trim()) {
27 newErrors.name = 'Le nom est requis';
28 isValid = false;
29 }
30
31 // Validation de l'email
32 if (!formData.email.trim()) {
33 newErrors.email = "L'email est requis";
34 isValid = false;
35 } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
36 newErrors.email = 'Fournis une adresse email valide';
37 isValid = false;
38 }
39
40 // Validation du mot de passe
41 if (!formData.password) {
42 newErrors.password = 'Le mot de passe est requis';
43 isValid = false;
44 } else if (formData.password.length < 8) {
45 newErrors.password = 'Le mot de passe doit contenir au moins 8 caractères';
46 isValid = false;
47 } else if (!/[A-Z]/.test(formData.password)) {
48 newErrors.password = 'Le mot de passe doit contenir au moins une majuscule';
49 isValid = false;
50 } else if (!/[0-9]/.test(formData.password)) {
51 newErrors.password = 'Le mot de passe doit contenir au moins un chiffre';
52 isValid = false;
53 }
54
55 setErrors(newErrors);
56 return isValid;
57 };
58
59 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
60 const { name, value } = e.target;
61
62 // Mettre à jour les données du formulaire
63 setFormData(prev => ({
64 ...prev,
65 [name]: value
66 }));
67
68 // Supprimer l'erreur du champ modifié
69 if (errors[name as keyof FormErrors]) {
70 setErrors(prev => {
71 const newErrors = { ...prev };
72 delete newErrors[name as keyof FormErrors];
73 return newErrors;
74 });
75 }
76 };
77
78 const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
79 e.preventDefault();
80
81 // Réinitialiser l'état avant l'envoi
82 setErrors({});
83 setSubmitSuccess(false);
84
85 // Valider le formulaire
86 if (!validateForm()) {
87 return;
88 }
89
90 setIsSubmitting(true);
91
92 try {
93 // Simuler un envoi à l'API avec une erreur aléatoire
94 await new Promise(resolve => setTimeout(resolve, 1000));
95
96 // Simuler une erreur serveur aléatoire (25 % de chance)
97 if (Math.random() < 0.25) {
98 throw new Error("Une erreur s'est produite lors du traitement du formulaire");
99 }
100
101 // Simuler une erreur métier - email déjà existant (25 % de chance)
102 if (Math.random() < 0.25) {
103 setErrors({
104 email: 'Cette adresse email est déjà enregistrée'
105 });
106 return;
107 }
108
109 // Succès
110 setSubmitSuccess(true);
111 setFormData({ name: '', email: '', password: '' });
112 console.log('Formulaire envoyé avec succès :', formData);
113 } catch (error) {
114 // Gérer l'erreur serveur
115 setErrors({
116 generalError: "Un problème s'est produit lors du traitement du formulaire. Réessaie plus tard."
117 });
118 console.error("Erreur d'envoi du formulaire :", error);
119 } finally {
120 setIsSubmitting(false);
121 }
122 };
123
124 return (
125 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
126 <h1 className="text-2xl font-bold mb-6">Inscription</h1>
127
128 {submitSuccess && (
129 <div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
130 Inscription réussie ! Consulte ton email pour activer ton compte.
131 </div>
132 )}
133
134 {errors.generalError && (
135 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" role="alert">
136 <p className="font-bold">Une erreur s'est produite</p>
137 <p>{errors.generalError}</p>
138 </div>
139 )}
140
141 <form onSubmit={handleSubmit} className="space-y-4" noValidate>
142 <div>
143 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
144 Nom complet
145 </label>
146 <input
147 type="text"
148 id="name"
149 name="name"
150 value={formData.name}
151 onChange={handleChange}
152 aria-invalid={Boolean(errors.name)}
153 aria-describedby={errors.name ? 'name-error' : undefined}
154 className={`mt-1 block w-full rounded-md shadow-sm ${errors.name ? 'border-red-500' : 'border-gray-300'}`}
155 />
156 {errors.name && (
157 <p className="mt-1 text-sm text-red-600" id="name-error" role="alert">
158 {errors.name}
159 </p>
160 )}
161 </div>
162
163 <div>
164 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
165 Email
166 </label>
167 <input
168 type="email"
169 id="email"
170 name="email"
171 value={formData.email}
172 onChange={handleChange}
173 aria-invalid={Boolean(errors.email)}
174 aria-describedby={errors.email ? 'email-error' : undefined}
175 className={`mt-1 block w-full rounded-md shadow-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
176 />
177 {errors.email && (
178 <p className="mt-1 text-sm text-red-600" id="email-error" role="alert">
179 {errors.email}
180 </p>
181 )}
182 </div>
183
184 <div>
185 <label htmlFor="password" className="block text-sm font-medium text-gray-700">
186 Mot de passe
187 </label>
188 <input
189 type="password"
190 id="password"
191 name="password"
192 value={formData.password}
193 onChange={handleChange}
194 aria-invalid={Boolean(errors.password)}
195 aria-describedby={errors.password ? 'password-error' : 'password-rules'}
196 className={`mt-1 block w-full rounded-md shadow-sm ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
197 />
198 {errors.password ? (
199 <p className="mt-1 text-sm text-red-600" id="password-error" role="alert">
200 {errors.password}
201 </p>
202 ) : (
203 <p className="mt-1 text-xs text-gray-500" id="password-rules">
204 Le mot de passe doit contenir au moins 8 caractères, dont une majuscule et un chiffre.
205 </p>
206 )}
207 </div>
208
209 <button
210 type="submit"
211 disabled={isSubmitting}
212 className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
213 >
214 {isSubmitting ? 'Traitement...' : "S'inscrire"}
215 </button>
216 </form>
217 </div>
218 );
219}Éléments clés de la gestion des erreurs dans cet exemple :
- État d'erreur - un objet séparé stockant les erreurs des champs individuels et une erreur générale
- Attributs d'accessibilité -
aria-invalidetaria-describedbypour les lecteurs d'écran - Validation dynamique - suppression des erreurs pendant l'édition d'un champ
- Indices visuels - bordure rouge pour les champs invalides
- Messages d'aide - indications sur les règles du mot de passe avant qu'une erreur ne survienne
Gestion des erreurs avec React Hook Form
React Hook Form offre des capacités avancées de gestion des erreurs :
1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4import { useState } from 'react';
5
6// Validation schema
7const signupSchema = z.object({
8 name: z.string().min(1, { message: 'Le nom est requis' }),
9 email: z.string().email({ message: 'Fournis une adresse email valide' }),
10 password: z.string()
11 .min(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
12 .regex(/[A-Z]/, { message: 'Le mot de passe doit contenir au moins une majuscule' })
13 .regex(/[0-9]/, { message: 'Le mot de passe doit contenir au moins un chiffre' })
14});
15
16type SignupFormInputs = z.infer<typeof signupSchema>;
17
18export default function HookFormSignup() {
19 const [serverError, setServerError] = useState<string | null>(null);
20 const [submitSuccess, setSubmitSuccess] = useState(false);
21
22 const {
23 register,
24 handleSubmit,
25 formState: { errors, isSubmitting, touchedFields, isDirty },
26 reset
27 } = useForm<SignupFormInputs>({
28 resolver: zodResolver(signupSchema),
29 mode: 'onTouched' // Validation au blur
30 });
31
32 const onSubmit: SubmitHandler<SignupFormInputs> = async (data) => {
33 // Réinitialiser les erreurs serveur et le succès avant une nouvelle tentative
34 setServerError(null);
35 setSubmitSuccess(false);
36
37 try {
38 // Simulate API submission
39 await new Promise(resolve => setTimeout(resolve, 1000));
40
41 // Simuler une erreur serveur aléatoire (25 % de chance)
42 if (Math.random() < 0.25) {
43 throw new Error("Une erreur s'est produite lors du traitement du formulaire");
44 }
45
46 // Simuler une erreur métier - email déjà existant (25 % de chance)
47 if (Math.random() < 0.25) {
48 throw new Error('Cette adresse email est déjà enregistrée');
49 }
50
51 // Succès
52 console.log('Formulaire envoyé avec succès :', data);
53 setSubmitSuccess(true);
54 reset(); // Vider le formulaire
55 } catch (error) {
56 if (error instanceof Error) {
57 setServerError(error.message);
58 } else {
59 setServerError("Une erreur inconnue s'est produite. Réessaie plus tard.");
60 }
61 console.error("Erreur d'envoi du formulaire :", error);
62 }
63 };
64
65 return (
66 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
67 <h1 className="text-2xl font-bold mb-6">Inscription (React Hook Form)</h1>
68
69 {submitSuccess && (
70 <div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
71 Inscription réussie ! Consulte ton email pour activer ton compte.
72 </div>
73 )}
74
75 {serverError && (
76 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" role="alert">
77 <p className="font-bold">Une erreur s'est produite</p>
78 <p>{serverError}</p>
79 </div>
80 )}
81
82 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
83 <div>
84 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
85 Full name
86 </label>
87 <input
88 id="name"
89 type="text"
90 aria-invalid={Boolean(errors.name)}
91 aria-describedby={errors.name ? 'name-error' : undefined}
92 className={`mt-1 block w-full rounded-md shadow-sm ${errors.name ? 'border-red-500' : touchedFields.name ? 'border-green-500' : 'border-gray-300'}`}
93 {...register('name')}
94 />
95 {errors.name && (
96 <p className="mt-1 text-sm text-red-600" id="name-error" role="alert">
97 {errors.name.message}
98 </p>
99 )}
100 </div>
101
102 <div>
103 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
104 Email
105 </label>
106 <input
107 id="email"
108 type="email"
109 aria-invalid={Boolean(errors.email)}
110 aria-describedby={errors.email ? 'email-error' : undefined}
111 className={`mt-1 block w-full rounded-md shadow-sm ${errors.email ? 'border-red-500' : touchedFields.email ? 'border-green-500' : 'border-gray-300'}`}
112 {...register('email')}
113 />
114 {errors.email && (
115 <p className="mt-1 text-sm text-red-600" id="email-error" role="alert">
116 {errors.email.message}
117 </p>
118 )}
119 </div>
120
121 <div>
122 <label htmlFor="password" className="block text-sm font-medium text-gray-700">
123 Mot de passe
124 </label>
125 <input
126 id="password"
127 type="password"
128 aria-invalid={Boolean(errors.password)}
129 aria-describedby={errors.password ? 'password-error' : 'password-rules'}
130 className={`mt-1 block w-full rounded-md shadow-sm ${errors.password ? 'border-red-500' : touchedFields.password ? 'border-green-500' : 'border-gray-300'}`}
131 {...register('password')}
132 />
133 {errors.password ? (
134 <p className="mt-1 text-sm text-red-600" id="password-error" role="alert">
135 {errors.password.message}
136 </p>
137 ) : (
138 <p className="mt-1 text-xs text-gray-500" id="password-rules">
139 Le mot de passe doit contenir au moins 8 caractères, dont une majuscule et un chiffre.
140 </p>
141 )}
142 </div>
143
144 <button
145 type="submit"
146 disabled={isSubmitting || !isDirty}
147 className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
148 >
149 {isSubmitting ? 'Traitement...' : "S'inscrire"}
150 </button>
151 </form>
152 </div>
153 );
154}Éléments clés de la gestion des erreurs dans React Hook Form :
- Validation avec Zod - des schémas de validation lisibles et typés
- Plusieurs états du formulaire -
isSubmitting,touchedFields,isDirty - Modes de validation - configuration avec
mode: 'onTouched' - Indicateurs d'état visuels - bordure verte pour les champs valides, rouge pour les invalides
- Intégration ARIA - accessibilité complète du formulaire
Gestion des erreurs dans les Server Actions (Next.js)
Dans les formulaires utilisant les Server Actions en Next.js, la gestion des erreurs est particulièrement importante car le traitement se fait sur le serveur :
1// app/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6const contactSchema = z.object({
7 name: z.string().min(1, { message: 'Le nom est requis' }),
8 email: z.string().email({ message: 'Fournis une adresse email valide' }),
9 subject: z.string().min(3, { message: 'Le sujet est trop court' }),
10 message: z.string().min(10, { message: 'Le message doit contenir au moins 10 caractères' })
11});
12
13export type ContactFormState = {
14 errors?: {
15 name?: string[];
16 email?: string[];
17 subject?: string[];
18 message?: string[];
19 _form?: string[];
20 };
21 message?: string;
22 success?: boolean;
23};
24
25export async function submitContactForm(
26 prevState: ContactFormState,
27 formData: FormData
28): Promise<ContactFormState> {
29 // Convertir FormData en objet
30 const rawFormData = {
31 name: formData.get('name'),
32 email: formData.get('email'),
33 subject: formData.get('subject'),
34 message: formData.get('message')
35 };
36
37 // Valider les données
38 const validationResult = contactSchema.safeParse(rawFormData);
39
40 // Si la validation échoue
41 if (!validationResult.success) {
42 // Convertir les erreurs Zod au format du formulaire
43 return {
44 errors: validationResult.error.flatten().fieldErrors,
45 message: 'Corrige les erreurs du formulaire',
46 success: false
47 };
48 }
49
50 try {
51 // Simuler un délai de l'API
52 await new Promise(resolve => setTimeout(resolve, 1000));
53
54 // Simuler une erreur serveur aléatoire (15 % de chance)
55 if (Math.random() < 0.15) {
56 throw new Error('Problème de connexion au serveur');
57 }
58
59 // Simuler une erreur de spam (10 % de chance)
60 if (Math.random() < 0.1) {
61 return {
62 errors: {
63 _form: ['Spam possible détecté. Réessaie plus tard.']
64 },
65 success: false
66 };
67 }
68
69 // Simuler le traitement du formulaire
70 console.log('Traitement du formulaire de contact :', validationResult.data);
71
72 return {
73 message: 'Le message a été envoyé avec succès. Nous te répondrons dès que possible.',
74 success: true
75 };
76 } catch (error) {
77 // Gérer les erreurs serveur
78 return {
79 errors: {
80 _form: [
81 "Une erreur s'est produite lors du traitement du formulaire. " +
82 'Réessaie plus tard ou contacte-nous directement.'
83 ]
84 },
85 success: false
86 };
87 }
88}Et le composant utilisant cette action :
1// app/contact/page.tsx
2'use client';
3
4import { useRef, useEffect } from 'react';
5import { useFormState, useFormStatus } from 'react-dom';
6import { submitContactForm, type ContactFormState } from '../actions';
7
8// Composant bouton d'envoi avec gestion de l'état de chargement
9function SubmitButton() {
10 const { pending } = useFormStatus();
11
12 return (
13 <button
14 type="submit"
15 disabled={pending}
16 aria-disabled={pending}
17 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
18 >
19 {pending ? 'Envoi...' : 'Envoyer le message'}
20 </button>
21 );
22}
23
24export default function ContactForm() {
25 const formRef = useRef<HTMLFormElement>(null);
26
27 // Initialiser l'état du formulaire
28 const initialState: ContactFormState = { errors: {} };
29
30 // Connexion avec la Server Action
31 const [state, formAction] = useFormState(submitContactForm, initialState);
32
33 // Réinitialiser le formulaire after successful submission
34 useEffect(() => {
35 if (state.success && formRef.current) {
36 formRef.current.reset();
37
38 // Mettre le focus sur le message de succès pour les lecteurs d'écran
39 const successMessage = document.getElementById('success-message');
40 if (successMessage) {
41 successMessage.focus();
42 }
43 }
44 }, [state.success]);
45
46 return (
47 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
48 <h1 className="text-2xl font-bold mb-6">Contacte-nous</h1>
49
50 {state.success && (
51 <div
52 id="success-message"
53 tabIndex={-1}
54 className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded"
55 role="alert"
56 >
57 {state.message}
58 </div>
59 )}
60
61 {state.errors?._form && (
62 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" role="alert">
63 <ul className="list-disc pl-5">
64 {state.errors._form.map((error, index) => (
65 <li key={index}>{error}</li>
66 ))}
67 </ul>
68 </div>
69 )}
70
71 <form ref={formRef} action={formAction} className="space-y-4" noValidate>
72 <div>
73 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
74 Full name
75 </label>
76 <input
77 type="text"
78 id="name"
79 name="name"
80 aria-invalid={Boolean(state.errors?.name)}
81 aria-describedby={state.errors?.name ? 'name-error' : undefined}
82 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.name ? 'border-red-500' : 'border-gray-300'}`}
83 />
84 {state.errors?.name && (
85 <p className="mt-1 text-sm text-red-600" id="name-error" role="alert">
86 {state.errors.name[0]}
87 </p>
88 )}
89 </div>
90
91 <div>
92 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
93 Email
94 </label>
95 <input
96 type="email"
97 id="email"
98 name="email"
99 aria-invalid={Boolean(state.errors?.email)}
100 aria-describedby={state.errors?.email ? 'email-error' : undefined}
101 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.email ? 'border-red-500' : 'border-gray-300'}`}
102 />
103 {state.errors?.email && (
104 <p className="mt-1 text-sm text-red-600" id="email-error" role="alert">
105 {state.errors.email[0]}
106 </p>
107 )}
108 </div>
109
110 <div>
111 <label htmlFor="subject" className="block text-sm font-medium text-gray-700">
112 Sujet
113 </label>
114 <input
115 type="text"
116 id="subject"
117 name="subject"
118 aria-invalid={Boolean(state.errors?.subject)}
119 aria-describedby={state.errors?.subject ? 'subject-error' : undefined}
120 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.subject ? 'border-red-500' : 'border-gray-300'}`}
121 />
122 {state.errors?.subject && (
123 <p className="mt-1 text-sm text-red-600" id="subject-error" role="alert">
124 {state.errors.subject[0]}
125 </p>
126 )}
127 </div>
128
129 <div>
130 <label htmlFor="message" className="block text-sm font-medium text-gray-700">
131 Message
132 </label>
133 <textarea
134 id="message"
135 name="message"
136 rows={4}
137 aria-invalid={Boolean(state.errors?.message)}
138 aria-describedby={state.errors?.message ? 'message-error' : undefined}
139 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.message ? 'border-red-500' : 'border-gray-300'}`}
140 />
141 {state.errors?.message && (
142 <p className="mt-1 text-sm text-red-600" id="message-error" role="alert">
143 {state.errors.message[0]}
144 </p>
145 )}
146 </div>
147
148 <SubmitButton />
149 </form>
150 </div>
151 );
152}Éléments clés de la gestion des erreurs avec les Server Actions :
- Structure des erreurs - un format spécial avec les erreurs de champ et une erreur générale du formulaire
- useFormState et useFormStatus - hooks pour suivre l'état du formulaire et le processus d'envoi
- Focus après le succès - déplacer le focus vers le message de succès pour les utilisateurs de lecteurs d'écran
- Structure des données de réponse - format cohérent pour les réponses du serveur
- Plusieurs types d'erreurs - différents scénarios d'erreur pour différentes causes
Modèles avancés de gestion des erreurs
Contextes d'erreurs de formulaire
Pour les formulaires plus complexes, envisage de créer un contexte d'erreurs :
1// contexts/FormErrorContext.tsx
2import { createContext, useContext, ReactNode, useState } from 'react';
3
4type ErrorsType = Record<string, string[]>;
5
6interface FormErrorContextType {
7 errors: ErrorsType;
8 setErrors: (errors: ErrorsType) => void;
9 clearErrors: () => void;
10 clearFieldError: (fieldName: string) => void;
11 hasErrors: boolean;
12 getFieldErrors: (fieldName: string) => string[] | undefined;
13}
14
15const FormErrorContext = createContext<FormErrorContextType | null>(null);
16
17export function FormErrorProvider({ children }: { children: ReactNode }) {
18 const [errors, setErrors] = useState<ErrorsType>({});
19
20 const clearErrors = () => setErrors({});
21
22 const clearFieldError = (fieldName: string) => {
23 setErrors(prev => {
24 const newErrors = { ...prev };
25 delete newErrors[fieldName];
26 return newErrors;
27 });
28 };
29
30 const getFieldErrors = (fieldName: string) => errors[fieldName];
31
32 const hasErrors = Object.keys(errors).length > 0;
33
34 return (
35 <FormErrorContext.Provider value={{
36 errors,
37 setErrors,
38 clearErrors,
39 clearFieldError,
40 hasErrors,
41 getFieldErrors
42 }}>
43 {children}
44 </FormErrorContext.Provider>
45 );
46}
47
48export function useFormErrors() {
49 const context = useContext(FormErrorContext);
50 if (!context) {
51 throw new Error('useFormErrors must be used within a FormErrorProvider');
52 }
53 return context;
54}Composant d'erreur de formulaire (réutilisable)
1// components/FormError.tsx
2import { useId } from 'react';
3
4interface FormErrorProps {
5 errors?: string[];
6 id?: string;
7}
8
9export function FormError({ errors, id: propId }: FormErrorProps) {
10 const generatedId = useId();
11 const id = propId || generatedId;
12
13 if (!errors || errors.length === 0) {
14 return null;
15 }
16
17 return (
18 <div className="mt-1" id={id} role="alert">
19 {errors.map((error, index) => (
20 <p key={index} className="text-sm text-red-600">
21 {error}
22 </p>
23 ))}
24 </div>
25 );
26}Composant de champ de formulaire (réutilisable)
1// components/FormField.tsx
2import { InputHTMLAttributes, ReactNode, useId } from 'react';
3import { FormError } from './FormError';
4import { useFormErrors } from '../contexts/FormErrorContext';
5
6interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
7 label: string;
8 name: string;
9 errors?: string[];
10 children?: ReactNode;
11 helpText?: string;
12}
13
14export function FormField({
15 label,
16 name,
17 errors: propErrors,
18 children,
19 helpText,
20 ...rest
21}: FormFieldProps) {
22 const fieldId = useId();
23 const errorId = `${fieldId}-error`;
24 const helpId = `${fieldId}-help`;
25
26 // Nous pouvons utiliser le contexte des erreurs si les erreurs ne sont pas passées en prop
27 const { getFieldErrors } = useFormErrors();
28 const errors = propErrors || getFieldErrors(name);
29
30 const hasError = errors && errors.length > 0;
31
32 // Déterminer quel élément décrit ce champ (erreur ou texte d'aide)
33 const ariaDescribedBy = hasError ? errorId : helpText ? helpId : undefined;
34
35 return (
36 <div>
37 <label htmlFor={fieldId} className="block text-sm font-medium text-gray-700">
38 {label}
39 </label>
40
41 <div className="mt-1">
42 {children ? (
43 // Si des enfants sont passés (par ex. des champs personnalisés)
44 children
45 ) : (
46 // Rendre un input par défaut
47 <input
48 id={fieldId}
49 name={name}
50 aria-invalid={hasError}
51 aria-describedby={ariaDescribedBy}
52 className={`block w-full rounded-md shadow-sm ${hasError ? 'border-red-500' : 'border-gray-300'}`}
53 {...rest}
54 />
55 )}
56 </div>
57
58 {hasError ? (
59 // Afficher les erreurs
60 <FormError errors={errors} id={errorId} />
61 ) : helpText ? (
62 // Afficher le texte d'aide s'il n'y a pas d'erreurs
63 <p className="mt-1 text-xs text-gray-500" id={helpId}>
64 {helpText}
65 </p>
66 ) : null}
67 </div>
68 );
69}Exemple d'utilisation des composants réutilisables
1// app/registration/page.tsx
2import { FormErrorProvider } from '../contexts/FormErrorContext';
3import { FormField } from '../components/FormField';
4
5export default function RegistrationPage() {
6 return (
7 <FormErrorProvider>
8 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
9 <h1 className="text-2xl font-bold mb-6">Inscription</h1>
10
11 <form className="space-y-4" noValidate>
12 <FormField
13 label="Nom complet"
14 name="name"
15 type="text"
16 required
17 helpText="Saisis ton nom complet"
18 />
19
20 <FormField
21 label="Email"
22 name="email"
23 type="email"
24 required
25 helpText="Cette adresse servira à te connecter au compte"
26 />
27
28 <FormField
29 label="Mot de passe"
30 name="password"
31 type="password"
32 required
33 helpText="Le mot de passe doit contenir au moins 8 caractères, dont une majuscule et un chiffre"
34 />
35
36 {/* Champ personnalisé avec sa propre implémentation */}
37 <FormField
38 label="Catégorie de compte"
39 name="accountType"
40 helpText="Choisis le type de compte qui correspond à tes besoins"
41 >
42 <div className="flex space-x-4">
43 <label className="inline-flex items-center">
44 <input type="radio" name="accountType" value="personal" className="mr-2" />
45 Personnel
46 </label>
47 <label className="inline-flex items-center">
48 <input type="radio" name="accountType" value="business" className="mr-2" />
49 Professionnel
50 </label>
51 </div>
52 </FormField>
53
54 <button
55 type="submit"
56 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700"
57 >
58 S'inscrire
59 </button>
60 </form>
61 </div>
62 </FormErrorProvider>
63 );
64}Stratégies de gestion des erreurs et leur impact sur l'UX
Différentes stratégies de gestion des erreurs peuvent avoir un impact important sur l'expérience utilisateur :
1. Validation en temps réel vs à la soumission
Validation en temps réel :
- Avantages : Retour immédiat, moins de frustration à l'envoi
- Inconvénients : Peut être envahissante et agaçante si elle apparaît trop tôt
Validation à la soumission :
- Avantages : Moins distrayante, laisse d'abord l'utilisateur saisir les données
- Inconvénients : Peut être frustrante si l'utilisateur remplit un long formulaire puis doit revenir corriger des erreurs
Meilleure approche : Un compromis - validation au blur ou après la première tentative d'envoi
2. Validation côté client vs côté serveur
Validation côté client :
- Avantages : Rapide, sans latence réseau, économise les ressources serveur
- Inconvénients : Peut être contournée, ne reflète pas toujours les vraies règles métier
Validation côté serveur :
- Avantages : Fiable, cohérente, prend en compte l'état actuel du système
- Inconvénients : Retour différé, charge serveur plus élevée
Meilleure approche : Validation aux deux niveaux - client pour l'UX, serveur pour la sécurité
3. Validation par champ vs de tout le formulaire
Validation par champ individuel :
- Avantages : Information d'erreur précise, corrections plus faciles
- Inconvénients : Ne prend pas en compte les dépendances entre champs
Validation de tout le formulaire :
- Avantages : Peut prendre en compte les dépendances entre champs
- Inconvénients : Peut être envahissante si le formulaire comporte de nombreuses erreurs
Meilleure approche : Validation par champ pendant la saisie et validation complète à l'envoi
Bonnes pratiques pour la gestion des erreurs de formulaire
Regroupe les erreurs liées - place les erreurs de tout le formulaire en haut, les erreurs de champ précises à côté des champs
Maintiens une cohérence visuelle - utilise les mêmes couleurs, icônes et styles pour les erreurs dans toute l'application
Peaufine tes messages - au lieu de « Données invalides », écris précisément ce qui ne va pas et comment le corriger
Préserve les données en cas d'erreur - ne réinitialise pas tout le formulaire après une erreur
Hiérarchise les erreurs - montre d'abord les erreurs critiques, puis les moins importantes
Défilement automatique vers les erreurs - surtout dans les longs formulaires, fais défiler automatiquement jusqu'à la première erreur
Utilise ARIA - utilise les attributs
aria-invalid,aria-describedbyet role="alert" pour l'accessibilitéMarque les champs requis - indique clairement quels champs sont requis, idéalement avant que l'utilisateur commence à remplir le formulaire
Fournis des exemples - pour les formats complexes (par ex. date, numéro de téléphone) fournis des exemples
Teste avec des utilisateurs - teste régulièrement les formulaires avec de vrais utilisateurs pour identifier les problèmes
Gestion des erreurs et accessibilité
Une gestion efficace des erreurs est particulièrement importante pour les utilisateurs en situation de handicap :
Utilise les attributs ARIA
aria-invalid="true"pour les champs invalidesaria-describedbyreliant le champ au message d'erreurrole="alert"pour les messages d'erreur qui doivent être lus immédiatement
Ne te fie pas uniquement à la couleur
- Ajoute des icônes ou d'autres indices visuels en plus du rouge
- Assure un contraste suffisant entre le texte et le fond
Utilise
<fieldset>et<legend>- Regroupe les champs liés avec
<fieldset> - Décris les groupes de champs avec
<legend>
- Regroupe les champs liés avec
Ordre de tabulation correct
- Assure-toi que les utilisateurs clavier peuvent se déplacer entre les champs dans un ordre logique
Résumé des erreurs
- Pour les formulaires complexes, ajoute un résumé des erreurs en haut du formulaire
- Chaque erreur doit contenir un lien vers le champ correspondant
Résumé
Une gestion efficace des erreurs de formulaire, c'est bien plus que d'informer l'utilisateur des problèmes. C'est une approche globale qui combine :
- Concevoir des messages conviviaux - clairs, précis et utiles
- Validation à plusieurs niveaux - client + serveur, champs individuels + formulaire complet
- Un bon timing - choisir le moment optimal pour afficher les erreurs
- Accessibilité - garantir que tous les utilisateurs peuvent comprendre et corriger les erreurs
- Cohérence visuelle - des styles et comportements uniformes dans toute l'application
Souviens-toi que l'objectif n'est pas seulement de détecter les erreurs, mais surtout d'aider l'utilisateur à les corriger efficacement et à compléter le formulaire avec succès. Une gestion des erreurs bien conçue peut nettement améliorer les taux de conversion et la satisfaction des utilisateurs.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Gestion des erreurs de formulaire - messages, UX, strategies d'affichage
4
5type ValidationStrategy = 'onSubmit' | 'onBlur' | 'onChange';
6
7interface FieldState {
8 value: string;
9 error: string;
10 touched: boolean;
11}
12
13const validateField = (name: string, value: string): string => {
14 switch (name) {
15 case 'name':
16 if (!value) return 'Le nom est requis';
17 if (value.length < 2) return 'Le nom doit comporter min. 2 caracteres';
18 return '';
19 case 'email':
20 if (!value) return 'L\'email est requis';
21 if (!/\S+@\S+\.\S+/.test(value)) return 'Format d\'email invalide';
22 return '';
23 case 'password':
24 if (!value) return 'Le mot de passe est requis';
25 if (value.length < 8) return 'Le mot de passe doit comporter min. 8 caracteres';
26 if (!/[A-Z]/.test(value)) return 'Le mot de passe doit contenir une majuscule';
27 if (!/[0-9]/.test(value)) return 'Le mot de passe doit contenir un chiffre';
28 return '';
29 default: return '';
30 }
31};
32
33export default function ErrorHandlingDemo() {
34 const [strategy, setStrategy] = useState<ValidationStrategy>('onBlur');
35 const [fields, setFields] = useState<Record<string, FieldState>>({
36 name: { value: '', error: '', touched: false },
37 email: { value: '', error: '', touched: false },
38 password: { value: '', error: '', touched: false },
39 });
40 const [submitted, setSubmitted] = useState(false);
41
42 const updateField = (name: string, value: string) => {
43 const error = strategy === 'onChange' ? validateField(name, value) : fields[name].error;
44 setFields(prev => ({ ...prev, [name]: { ...prev[name], value, error } }));
45 };
46
47 const blurField = (name: string) => {
48 if (strategy === 'onBlur' || strategy === 'onChange') {
49 const error = validateField(name, fields[name].value);
50 setFields(prev => ({ ...prev, [name]: { ...prev[name], touched: true, error } }));
51 }
52 };
53
54 const handleSubmit = (e: React.FormEvent) => {
55 e.preventDefault();
56 const newFields = { ...fields };
57 let valid = true;
58 for (const name of Object.keys(newFields)) {
59 const error = validateField(name, newFields[name].value);
60 newFields[name] = { ...newFields[name], error, touched: true };
61 if (error) valid = false;
62 }
63 setFields(newFields);
64 setSubmitted(valid);
65 };
66
67 const inputStyle = (hasError: boolean): React.CSSProperties => ({
68 width: '100%', padding: '10px', background: '#0d1117',
69 border: `1px solid ${hasError ? '#f44336' : '#333'}`, borderRadius: 6,
70 color: '#e0e0e0', fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box' as const
71 });
72
73 const strategies: { value: ValidationStrategy; label: string }[] = [
74 { value: 'onSubmit', label: 'A la soumission' },
75 { value: 'onBlur', label: 'A la perte du focus' },
76 { value: 'onChange', label: 'A chaque changement' },
77 ];
78
79 return (
80 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
81 <h1 style={{ color: '#64ffda' }}>Gestion des erreurs de formulaire</h1>
82
83 <div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
84 {strategies.map(s => (
85 <button key={s.value} onClick={() => {
86 setStrategy(s.value);
87 setFields(Object.fromEntries(Object.keys(fields).map(k => [k, { value: '', error: '', touched: false }])));
88 setSubmitted(false);
89 }} style={{
90 background: strategy === s.value ? '#1565c0' : '#1a2744',
91 color: '#fff', border: `1px solid ${strategy === s.value ? '#64ffda' : '#333'}`,
92 borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace'
93 }}>
94 {s.label}
95 </button>
96 ))}
97 </div>
98
99 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
100 {Object.entries(fields).map(([name, field]) => (
101 <div key={name} style={{ marginBottom: 12 }}>
102 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>{name}</label>
103 <input
104 type={name === 'password' ? 'password' : 'text'}
105 value={field.value}
106 onChange={e => updateField(name, e.target.value)}
107 onBlur={() => blurField(name)}
108 style={inputStyle(!!field.error && field.touched)}
109 />
110 {field.error && field.touched && (
111 <div style={{ color: '#f44336', fontSize: 11, marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
112 <span>⚠</span> {field.error}
113 </div>
114 )}
115 </div>
116 ))}
117 <button type="submit" style={{
118 background: '#f44336', color: '#fff', border: 'none', borderRadius: 6,
119 padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
120 }}>
121 Envoyer
122 </button>
123 </form>
124
125 {submitted && (
126 <div style={{ marginTop: 16, background: '#1b5e20', borderRadius: 8, padding: 12, maxWidth: 400, border: '1px solid #4caf50' }}>
127 Formulaire envoye avec succes !
128 </div>
129 )}
130 </div>
131 );
132}Tu as repéré une erreur dans cette leçon ?
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne la chaîne de validation Zod pour un champ e-mail d'au moins 5 caractères
- Clique dans l'ordre
Ordonne la syntaxe de configuration du formulaire avec le resolver Zod