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

Gestion des erreurs et messages dans les formulaires

20 min de lecture
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 :

  1. Erreurs de validation - liées à des données incorrectes saisies par l'utilisateur (par ex. format d'email invalide)
  2. Erreurs serveur - problèmes lors du traitement des données côté serveur (par ex. erreur de base de données)
  3. Erreurs réseau - problèmes de connexion (par ex. délai dépassé, hors ligne)
  4. Erreurs métier - les données sont techniquement correctes mais violent des règles métier (par ex. adresse email en double)
  5. 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 :

  1. État d'erreur - un objet séparé stockant les erreurs des champs individuels et une erreur générale
  2. Attributs d'accessibilité - aria-invalid et aria-describedby pour les lecteurs d'écran
  3. Validation dynamique - suppression des erreurs pendant l'édition d'un champ
  4. Indices visuels - bordure rouge pour les champs invalides
  5. 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 :

  1. Validation avec Zod - des schémas de validation lisibles et typés
  2. Plusieurs états du formulaire - isSubmitting, touchedFields, isDirty
  3. Modes de validation - configuration avec mode: 'onTouched'
  4. Indicateurs d'état visuels - bordure verte pour les champs valides, rouge pour les invalides
  5. 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 :

  1. Structure des erreurs - un format spécial avec les erreurs de champ et une erreur générale du formulaire
  2. useFormState et useFormStatus - hooks pour suivre l'état du formulaire et le processus d'envoi
  3. Focus après le succès - déplacer le focus vers le message de succès pour les utilisateurs de lecteurs d'écran
  4. Structure des données de réponse - format cohérent pour les réponses du serveur
  5. 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

  1. Regroupe les erreurs liées - place les erreurs de tout le formulaire en haut, les erreurs de champ précises à côté des champs

  2. Maintiens une cohérence visuelle - utilise les mêmes couleurs, icônes et styles pour les erreurs dans toute l'application

  3. Peaufine tes messages - au lieu de « Données invalides », écris précisément ce qui ne va pas et comment le corriger

  4. Préserve les données en cas d'erreur - ne réinitialise pas tout le formulaire après une erreur

  5. Hiérarchise les erreurs - montre d'abord les erreurs critiques, puis les moins importantes

  6. Défilement automatique vers les erreurs - surtout dans les longs formulaires, fais défiler automatiquement jusqu'à la première erreur

  7. Utilise ARIA - utilise les attributs aria-invalid, aria-describedby et role="alert" pour l'accessibilité

  8. Marque les champs requis - indique clairement quels champs sont requis, idéalement avant que l'utilisateur commence à remplir le formulaire

  9. Fournis des exemples - pour les formats complexes (par ex. date, numéro de téléphone) fournis des exemples

  10. 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 :

  1. Utilise les attributs ARIA

    • aria-invalid="true" pour les champs invalides
    • aria-describedby reliant le champ au message d'erreur
    • role="alert" pour les messages d'erreur qui doivent être lus immédiatement
  2. 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
  3. Utilise <fieldset> et <legend>

    • Regroupe les champs liés avec <fieldset>
    • Décris les groupes de champs avec <legend>
  4. Ordre de tabulation correct

    • Assure-toi que les utilisateurs clavier peuvent se déplacer entre les champs dans un ordre logique
  5. 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 :

  1. Concevoir des messages conviviaux - clairs, précis et utiles
  2. Validation à plusieurs niveaux - client + serveur, champs individuels + formulaire complet
  3. Un bon timing - choisir le moment optimal pour afficher les erreurs
  4. Accessibilité - garantir que tous les utilisateurs peuvent comprendre et corriger les erreurs
  5. 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>&#9888;</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

Articles utiles