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

Formulaires multi-étapes et parcours

38 min de lecture
Dans cette leçon7

Créer des formulaires complexes et multi-étapes est une tâche courante dans les applications web. De tels formulaires sont fréquemment utilisés pour les processus d'inscription, les réservations, les commandes ou la configuration de produits. Dans ce module, nous examinerons différentes stratégies d'implémentation des formulaires multi-étapes dans les environnements React et Next.js.

Pourquoi découper les formulaires en étapes ?

Découper un long formulaire en étapes plus petites apporte de nombreux avantages :

  1. Meilleure expérience utilisateur - les formulaires plus courts sont moins accablants et réduisent le risque d'abandon
  2. Plus de clarté - les utilisateurs peuvent se concentrer plus facilement sur un aspect du processus à la fois
  3. Collecte progressive des données - nous pouvons valider chaque étape séparément et adapter les étapes suivantes selon les réponses précédentes
  4. Organisation logique - regrouper les champs liés améliore la structure et la compréhension
  5. Sauvegarde partielle - les utilisateurs peuvent sauvegarder leur progression et revenir plus tard

Approches de base des formulaires multi-étapes

Il existe plusieurs approches principales pour implémenter des formulaires multi-étapes :

1. Approche basée sur l'état du composant

La manière la plus simple consiste à stocker l'étape actuelle et toutes les données du formulaire dans l'état du composant :

1'use client';
2
3import { useState, FormEvent } from 'react';
4
5// Types de données pour chaque étape
6interface UserDetails {
7  firstName: string;
8  lastName: string;
9  email: string;
10}
11
12interface AddressDetails {
13  street: string;
14  city: string;
15  zipCode: string;
16  country: string;
17}
18
19interface PaymentDetails {
20  cardNumber: string;
21  expiryDate: string;
22  cvv: string;
23}
24
25// Données combinées du formulaire
26interface FormData {
27  user: UserDetails;
28  address: AddressDetails;
29  payment: PaymentDetails;
30}
31
32export default function MultiStepForm() {
33  // État de l'étape actuelle
34  const [step, setStep] = useState(1);
35
36  // État des données du formulaire
37  const [formData, setFormData] = useState<FormData>({
38    user: {
39      firstName: '',
40      lastName: '',
41      email: ''
42    },
43    address: {
44      street: '',
45      city: '',
46      zipCode: '',
47      country: ''
48    },
49    payment: {
50      cardNumber: '',
51      expiryDate: '',
52      cvv: ''
53    }
54  });
55
56  // État de validation
57  const [errors, setErrors] = useState<Record<string, string>>({});
58
59  // Gérer les changements des champs du formulaire
60  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
61    const { name, value } = e.target;
62
63    // Déterminer à quelle étape appartient le champ
64    if (['firstName', 'lastName', 'email'].includes(name)) {
65      setFormData({
66        ...formData,
67        user: {
68          ...formData.user,
69          [name]: value
70        }
71      });
72    } else if (['street', 'city', 'zipCode', 'country'].includes(name)) {
73      setFormData({
74        ...formData,
75        address: {
76          ...formData.address,
77          [name]: value
78        }
79      });
80    } else if (['cardNumber', 'expiryDate', 'cvv'].includes(name)) {
81      setFormData({
82        ...formData,
83        payment: {
84          ...formData.payment,
85          [name]: value
86        }
87      });
88    }
89
90    // Supprimer l'erreur après l'édition du champ
91    if (errors[name]) {
92      setErrors({
93        ...errors,
94        [name]: ''
95      });
96    }
97  }
98
99  // Valider les données du formulaire pour chaque étape
100  function validateStep(stepNumber: number): boolean {
101    const newErrors: Record<string, string> = {};
102
103    if (stepNumber === 1) {
104      // Valider les données de l'utilisateur
105      if (!formData.user.firstName.trim()) {
106        newErrors.firstName = 'Le prénom est requis';
107      }
108
109      if (!formData.user.lastName.trim()) {
110        newErrors.lastName = 'Le nom de famille est requis';
111      }
112
113      if (!formData.user.email.trim()) {
114        newErrors.email = "L'email est requis";
115      } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(formData.user.email)) {
116        newErrors.email = "Format d'adresse email invalide";
117      }
118    } else if (stepNumber === 2) {
119      // Validation de l'adresse
120      if (!formData.address.street.trim()) {
121        newErrors.street = 'La rue est requise';
122      }
123
124      if (!formData.address.city.trim()) {
125        newErrors.city = 'La ville est requise';
126      }
127
128      if (!formData.address.zipCode.trim()) {
129        newErrors.zipCode = 'Le code postal est requis';
130      }
131
132      if (!formData.address.country.trim()) {
133        newErrors.country = 'Le pays est requis';
134      }
135    } else if (stepNumber === 3) {
136      // Validation du paiement
137      if (!formData.payment.cardNumber.trim()) {
138        newErrors.cardNumber = 'Le numéro de carte est requis';
139      } else if (!/^\d{16}$/.test(formData.payment.cardNumber.replace(/\s/g, ''))) {
140        newErrors.cardNumber = 'Numéro de carte invalide';
141      }
142
143      if (!formData.payment.expiryDate.trim()) {
144        newErrors.expiryDate = "La date d'expiration est requise";
145      }
146
147      if (!formData.payment.cvv.trim()) {
148        newErrors.cvv = 'Le code CVV est requis';
149      } else if (!/^\d{3,4}$/.test(formData.payment.cvv)) {
150        newErrors.cvv = 'Code CVV invalide';
151      }
152    }
153
154    setErrors(newErrors);
155    return Object.keys(newErrors).length === 0;
156  }
157
158  // Passer à l'étape suivante
159  function handleNext() {
160    const isValid = validateStep(step);
161
162    if (isValid) {
163      setStep(step + 1);
164    }
165  }
166
167  // Revenir à l'étape précédente
168  function handlePrevious() {
169    setStep(step - 1);
170  }
171
172  // Soumettre l'ensemble du formulaire
173  function handleSubmit(e: FormEvent) {
174    e.preventDefault();
175
176    const isValid = validateStep(step);
177
178    if (isValid) {
179      // Code pour envoyer les données à l'API
180      console.log('Envoi des données du formulaire :', formData);
181
182      // Exemple d'appel d'API (dans une vraie application)
183      // await fetch('/api/checkout', {
184      //   method: 'POST',
185      //   headers: { 'Content-Type': 'application/json' },
186      //   body: JSON.stringify(formData)
187      // });
188
189      // Facultatif : rediriger vers la page de confirmation
190      // router.push('/checkout/success');
191    }
192  }
193
194  // Rendre l'étape appropriée
195  function renderStep() {
196    switch (step) {
197      case 1:
198        return (
199          <div className="space-y-4">
200            <h2 className="text-xl font-semibold">Données personnelles</h2>
201
202            <div>
203              <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
204                Prénom
205              </label>
206              <input
207                type="text"
208                id="firstName"
209                name="firstName"
210                value={formData.user.firstName}
211                onChange={handleChange}
212                className={`mt-1 block w-full rounded-md border ${
213                  errors.firstName ? 'border-red-500' : 'border-gray-300'
214                } px-3 py-2`}
215              />
216              {errors.firstName && (
217                <p className="mt-1 text-sm text-red-600">{errors.firstName}</p>
218              )}
219            </div>
220
221            <div>
222              <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
223                Nom de famille
224              </label>
225              <input
226                type="text"
227                id="lastName"
228                name="lastName"
229                value={formData.user.lastName}
230                onChange={handleChange}
231                className={`mt-1 block w-full rounded-md border ${
232                  errors.lastName ? 'border-red-500' : 'border-gray-300'
233                } px-3 py-2`}
234              />
235              {errors.lastName && (
236                <p className="mt-1 text-sm text-red-600">{errors.lastName}</p>
237              )}
238            </div>
239
240            <div>
241              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
242                Email
243              </label>
244              <input
245                type="email"
246                id="email"
247                name="email"
248                value={formData.user.email}
249                onChange={handleChange}
250                className={`mt-1 block w-full rounded-md border ${
251                  errors.email ? 'border-red-500' : 'border-gray-300'
252                } px-3 py-2`}
253              />
254              {errors.email && (
255                <p className="mt-1 text-sm text-red-600">{errors.email}</p>
256              )}
257            </div>
258
259            <div className="flex justify-end">
260              <button
261                type="button"
262                onClick={handleNext}
263                className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
264              >
265                Suivant
266              </button>
267            </div>
268          </div>
269        );
270
271      case 2:
272        return (
273          <div className="space-y-4">
274            <h2 className="text-xl font-semibold">Adresse de livraison</h2>
275
276            <div>
277              <label htmlFor="street" className="block text-sm font-medium text-gray-700">
278                Rue et numéro
279              </label>
280              <input
281                type="text"
282                id="street"
283                name="street"
284                value={formData.address.street}
285                onChange={handleChange}
286                className={`mt-1 block w-full rounded-md border ${
287                  errors.street ? 'border-red-500' : 'border-gray-300'
288                } px-3 py-2`}
289              />
290              {errors.street && (
291                <p className="mt-1 text-sm text-red-600">{errors.street}</p>
292              )}
293            </div>
294
295            <div>
296              <label htmlFor="city" className="block text-sm font-medium text-gray-700">
297                Ville
298              </label>
299              <input
300                type="text"
301                id="city"
302                name="city"
303                value={formData.address.city}
304                onChange={handleChange}
305                className={`mt-1 block w-full rounded-md border ${
306                  errors.city ? 'border-red-500' : 'border-gray-300'
307                } px-3 py-2`}
308              />
309              {errors.city && (
310                <p className="mt-1 text-sm text-red-600">{errors.city}</p>
311              )}
312            </div>
313
314            <div>
315              <label htmlFor="zipCode" className="block text-sm font-medium text-gray-700">
316                Code postal
317              </label>
318              <input
319                type="text"
320                id="zipCode"
321                name="zipCode"
322                value={formData.address.zipCode}
323                onChange={handleChange}
324                className={`mt-1 block w-full rounded-md border ${
325                  errors.zipCode ? 'border-red-500' : 'border-gray-300'
326                } px-3 py-2`}
327              />
328              {errors.zipCode && (
329                <p className="mt-1 text-sm text-red-600">{errors.zipCode}</p>
330              )}
331            </div>
332
333            <div>
334              <label htmlFor="country" className="block text-sm font-medium text-gray-700">
335                Pays
336              </label>
337              <input
338                type="text"
339                id="country"
340                name="country"
341                value={formData.address.country}
342                onChange={handleChange}
343                className={`mt-1 block w-full rounded-md border ${
344                  errors.country ? 'border-red-500' : 'border-gray-300'
345                } px-3 py-2`}
346              />
347              {errors.country && (
348                <p className="mt-1 text-sm text-red-600">{errors.country}</p>
349              )}
350            </div>
351
352            <div className="flex justify-between">
353              <button
354                type="button"
355                onClick={handlePrevious}
356                className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
357              >
358                Retour
359              </button>
360              <button
361                type="button"
362                onClick={handleNext}
363                className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
364              >
365                Next
366              </button>
367            </div>
368          </div>
369        );
370
371      case 3:
372        return (
373          <div className="space-y-4">
374            <h2 className="text-xl font-semibold">Détails du paiement</h2>
375
376            <div>
377              <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
378                Numéro de carte
379              </label>
380              <input
381                type="text"
382                id="cardNumber"
383                name="cardNumber"
384                value={formData.payment.cardNumber}
385                onChange={handleChange}
386                className={`mt-1 block w-full rounded-md border ${
387                  errors.cardNumber ? 'border-red-500' : 'border-gray-300'
388                } px-3 py-2`}
389              />
390              {errors.cardNumber && (
391                <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
392              )}
393            </div>
394
395            <div className="grid grid-cols-2 gap-4">
396              <div>
397                <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">
398                  Date d'expiration
399                </label>
400                <input
401                  type="text"
402                  id="expiryDate"
403                  name="expiryDate"
404                  value={formData.payment.expiryDate}
405                  onChange={handleChange}
406                  className={`mt-1 block w-full rounded-md border ${
407                    errors.expiryDate ? 'border-red-500' : 'border-gray-300'
408                  } px-3 py-2`}
409                />
410                {errors.expiryDate && (
411                  <p className="mt-1 text-sm text-red-600">{errors.expiryDate}</p>
412                )}
413              </div>
414
415              <div>
416                <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">
417                  CVV
418                </label>
419                <input
420                  type="text"
421                  id="cvv"
422                  name="cvv"
423                  value={formData.payment.cvv}
424                  onChange={handleChange}
425                  className={`mt-1 block w-full rounded-md border ${
426                    errors.cvv ? 'border-red-500' : 'border-gray-300'
427                  } px-3 py-2`}
428                />
429                {errors.cvv && (
430                  <p className="mt-1 text-sm text-red-600">{errors.cvv}</p>
431                )}
432              </div>
433            </div>
434
435            <div className="flex justify-between">
436              <button
437                type="button"
438                onClick={handlePrevious}
439                className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
440              >
441                Retour
442              </button>
443              <button
444                type="submit"
445                onClick={handleSubmit}
446                className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
447              >
448                Place order
449              </button>
450            </div>
451          </div>
452        );
453
454      default:
455        return null;
456    }
457  }
458
459  return (
460    <div className="max-w-2xl mx-auto px-4 py-8">
461      <div className="mb-8">
462        <div className="flex items-center justify-between mb-4">
463          <div className="flex items-center">
464            <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
465              step >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
466            }`}>
467              1
468            </div>
469            <div className={`h-1 w-12 ${step >= 2 ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
470          </div>
471          <div className="flex items-center">
472            <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
473              step >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
474            }`}>
475              2
476            </div>
477            <div className={`h-1 w-12 ${step >= 3 ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
478          </div>
479          <div className="flex items-center">
480            <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
481              step >= 3 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
482            }`}>
483              3
484            </div>
485          </div>
486        </div>
487        <div className="flex justify-between text-sm">
488          <span className={step >= 1 ? 'text-blue-500 font-medium' : ''}>Données personnelles</span>
489          <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adresse</span>
490          <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Paiement</span>
491        </div>
492      </div>
493
494      <form className="bg-white shadow-md rounded-lg p-6">
495        {renderStep()}
496      </form>
497    </div>
498  );
499}

Principales caractéristiques de cette approche :

  • Un seul composant gère tout le processus
  • L'état contient toutes les données du formulaire et l'étape actuelle
  • Le passage d'une étape à l'autre se fait en modifiant l'état
  • La validation est effectuée avant de passer à l'étape suivante

2. Approche par composant par étape

Une approche alternative consiste à créer des composants séparés pour chaque étape, ce qui améliore l'organisation et la réutilisabilité du code :

1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6// Types pour les données du formulaire
7interface UserFormData {
8  firstName: string;
9  lastName: string;
10  email: string;
11}
12
13interface AddressFormData {
14  street: string;
15  city: string;
16  zipCode: string;
17  country: string;
18}
19
20interface PaymentFormData {
21  cardNumber: string;
22  expiryDate: string;
23  cvv: string;
24}
25
26export type FormData = {
27  user: UserFormData;
28  address: AddressFormData;
29  payment: PaymentFormData;
30}
31
32// Composant pour l'étape 1 : Données de l'utilisateur
33interface UserFormProps {
34  data: UserFormData;
35  onSubmit: (data: UserFormData) => void;
36}
37
38function UserForm({ data, onSubmit }: UserFormProps) {
39  const [formData, setFormData] = useState(data);
40  const [errors, setErrors] = useState<Record<string, string>>({});
41
42  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
43    const { name, value } = e.target;
44    setFormData({ ...formData, [name]: value });
45
46    // Effacement des erreurs
47    if (errors[name]) {
48      setErrors({ ...errors, [name]: '' });
49    }
50  }
51
52  function validate(): boolean {
53    const newErrors: Record<string, string> = {};
54
55    if (!formData.firstName.trim()) {
56      newErrors.firstName = 'Le prénom est requis';
57    }
58
59    if (!formData.lastName.trim()) {
60      newErrors.lastName = 'Le nom de famille est requis';
61    }
62
63    if (!formData.email.trim()) {
64      newErrors.email = "L'email est requis";
65    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(formData.email)) {
66      newErrors.email = "Format d'adresse email invalide";
67    }
68
69    setErrors(newErrors);
70    return Object.keys(newErrors).length === 0;
71  }
72
73  function handleSubmit(e: React.FormEvent) {
74    e.preventDefault();
75
76    if (validate()) {
77      onSubmit(formData);
78    }
79  }
80
81  return (
82    <form onSubmit={handleSubmit} className="space-y-4">
83      <h2 className="text-xl font-semibold">Données personnelles</h2>
84
85      <div>
86        <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
87          Prénom
88        </label>
89        <input
90          type="text"
91          id="firstName"
92          name="firstName"
93          value={formData.firstName}
94          onChange={handleChange}
95          className={`mt-1 block w-full rounded-md border ${
96            errors.firstName ? 'border-red-500' : 'border-gray-300'
97          } px-3 py-2`}
98        />
99        {errors.firstName && (
100          <p className="mt-1 text-sm text-red-600">{errors.firstName}</p>
101        )}
102      </div>
103
104      <div>
105        <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
106          Nom de famille
107        </label>
108        <input
109          type="text"
110          id="lastName"
111          name="lastName"
112          value={formData.lastName}
113          onChange={handleChange}
114          className={`mt-1 block w-full rounded-md border ${
115            errors.lastName ? 'border-red-500' : 'border-gray-300'
116          } px-3 py-2`}
117        />
118        {errors.lastName && (
119          <p className="mt-1 text-sm text-red-600">{errors.lastName}</p>
120        )}
121      </div>
122
123      <div>
124        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
125          Email
126        </label>
127        <input
128          type="email"
129          id="email"
130          name="email"
131          value={formData.email}
132          onChange={handleChange}
133          className={`mt-1 block w-full rounded-md border ${
134            errors.email ? 'border-red-500' : 'border-gray-300'
135          } px-3 py-2`}
136        />
137        {errors.email && (
138          <p className="mt-1 text-sm text-red-600">{errors.email}</p>
139        )}
140      </div>
141
142      <div className="flex justify-end">
143        <button
144          type="submit"
145          className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
146        >
147          Suivant
148        </button>
149      </div>
150    </form>
151  );
152}
153
154// Composant pour l'étape 2 : Adresse de livraison
155interface AddressFormProps {
156  data: AddressFormData;
157  onSubmit: (data: AddressFormData) => void;
158  onBack: () => void;
159}
160
161function AddressForm({ data, onSubmit, onBack }: AddressFormProps) {
162  const [formData, setFormData] = useState(data);
163  const [errors, setErrors] = useState<Record<string, string>>({});
164
165  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
166    const { name, value } = e.target;
167    setFormData({ ...formData, [name]: value });
168
169    if (errors[name]) {
170      setErrors({ ...errors, [name]: '' });
171    }
172  }
173
174  function validate(): boolean {
175    const newErrors: Record<string, string> = {};
176
177    if (!formData.street.trim()) {
178      newErrors.street = 'La rue est requise';
179    }
180
181    if (!formData.city.trim()) {
182      newErrors.city = 'La ville est requise';
183    }
184
185    if (!formData.zipCode.trim()) {
186      newErrors.zipCode = 'Le code postal est requis';
187    }
188
189    if (!formData.country.trim()) {
190      newErrors.country = 'Le pays est requis';
191    }
192
193    setErrors(newErrors);
194    return Object.keys(newErrors).length === 0;
195  }
196
197  function handleSubmit(e: React.FormEvent) {
198    e.preventDefault();
199
200    if (validate()) {
201      onSubmit(formData);
202    }
203  }
204
205  return (
206    <form onSubmit={handleSubmit} className="space-y-4">
207      <h2 className="text-xl font-semibold">Adresse de livraison</h2>
208
209      <div>
210        <label htmlFor="street" className="block text-sm font-medium text-gray-700">
211          Rue et numéro
212        </label>
213        <input
214          type="text"
215          id="street"
216          name="street"
217          value={formData.street}
218          onChange={handleChange}
219          className={`mt-1 block w-full rounded-md border ${
220            errors.street ? 'border-red-500' : 'border-gray-300'
221          } px-3 py-2`}
222        />
223        {errors.street && (
224          <p className="mt-1 text-sm text-red-600">{errors.street}</p>
225        )}
226      </div>
227
228      <div>
229        <label htmlFor="city" className="block text-sm font-medium text-gray-700">
230          Ville
231        </label>
232        <input
233          type="text"
234          id="city"
235          name="city"
236          value={formData.city}
237          onChange={handleChange}
238          className={`mt-1 block w-full rounded-md border ${
239            errors.city ? 'border-red-500' : 'border-gray-300'
240          } px-3 py-2`}
241        />
242        {errors.city && (
243          <p className="mt-1 text-sm text-red-600">{errors.city}</p>
244        )}
245      </div>
246
247      <div>
248        <label htmlFor="zipCode" className="block text-sm font-medium text-gray-700">
249          Code postal
250        </label>
251        <input
252          type="text"
253          id="zipCode"
254          name="zipCode"
255          value={formData.zipCode}
256          onChange={handleChange}
257          className={`mt-1 block w-full rounded-md border ${
258            errors.zipCode ? 'border-red-500' : 'border-gray-300'
259          } px-3 py-2`}
260        />
261        {errors.zipCode && (
262          <p className="mt-1 text-sm text-red-600">{errors.zipCode}</p>
263        )}
264      </div>
265
266      <div>
267        <label htmlFor="country" className="block text-sm font-medium text-gray-700">
268          Pays
269        </label>
270        <input
271          type="text"
272          id="country"
273          name="country"
274          value={formData.country}
275          onChange={handleChange}
276          className={`mt-1 block w-full rounded-md border ${
277            errors.country ? 'border-red-500' : 'border-gray-300'
278          } px-3 py-2`}
279        />
280        {errors.country && (
281          <p className="mt-1 text-sm text-red-600">{errors.country}</p>
282        )}
283      </div>
284
285      <div className="flex justify-between">
286        <button
287          type="button"
288          onClick={onBack}
289          className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
290        >
291          Retour
292        </button>
293        <button
294          type="submit"
295          className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
296        >
297          Suivant
298        </button>
299      </div>
300    </form>
301  );
302}
303
304// Composant pour l'étape 3 : Paiement
305interface PaymentFormProps {
306  data: PaymentFormData;
307  onSubmit: (data: PaymentFormData) => void;
308  onBack: () => void;
309}
310
311function PaymentForm({ data, onSubmit, onBack }: PaymentFormProps) {
312  const [formData, setFormData] = useState(data);
313  const [errors, setErrors] = useState<Record<string, string>>({});
314
315  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
316    const { name, value } = e.target;
317    setFormData({ ...formData, [name]: value });
318
319    if (errors[name]) {
320      setErrors({ ...errors, [name]: '' });
321    }
322  }
323
324  function validate(): boolean {
325    const newErrors: Record<string, string> = {};
326
327    if (!formData.cardNumber.trim()) {
328      newErrors.cardNumber = 'Le numéro de carte est requis';
329    } else if (!/^\d{16}$/.test(formData.cardNumber.replace(/\s/g, ''))) {
330      newErrors.cardNumber = 'Numéro de carte invalide';
331    }
332
333    if (!formData.expiryDate.trim()) {
334      newErrors.expiryDate = "La date d'expiration est requise";
335    }
336
337    if (!formData.cvv.trim()) {
338      newErrors.cvv = 'Le code CVV est requis';
339    } else if (!/^\d{3,4}$/.test(formData.cvv)) {
340      newErrors.cvv = 'Code CVV invalide';
341    }
342
343    setErrors(newErrors);
344    return Object.keys(newErrors).length === 0;
345  }
346
347  function handleSubmit(e: React.FormEvent) {
348    e.preventDefault();
349
350    if (validate()) {
351      onSubmit(formData);
352    }
353  }
354
355  return (
356    <form onSubmit={handleSubmit} className="space-y-4">
357      <h2 className="text-xl font-semibold">Détails du paiement</h2>
358
359      <div>
360        <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
361          Numéro de carte
362        </label>
363        <input
364          type="text"
365          id="cardNumber"
366          name="cardNumber"
367          value={formData.cardNumber}
368          onChange={handleChange}
369          className={`mt-1 block w-full rounded-md border ${
370            errors.cardNumber ? 'border-red-500' : 'border-gray-300'
371          } px-3 py-2`}
372        />
373        {errors.cardNumber && (
374          <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
375        )}
376      </div>
377
378      <div className="grid grid-cols-2 gap-4">
379        <div>
380          <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">
381            Date d'expiration
382          </label>
383          <input
384            type="text"
385            id="expiryDate"
386            name="expiryDate"
387            value={formData.expiryDate}
388            onChange={handleChange}
389            className={`mt-1 block w-full rounded-md border ${
390              errors.expiryDate ? 'border-red-500' : 'border-gray-300'
391            } px-3 py-2`}
392          />
393          {errors.expiryDate && (
394            <p className="mt-1 text-sm text-red-600">{errors.expiryDate}</p>
395          )}
396        </div>
397
398        <div>
399          <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">
400            CVV
401          </label>
402          <input
403            type="text"
404            id="cvv"
405            name="cvv"
406            value={formData.cvv}
407            onChange={handleChange}
408            className={`mt-1 block w-full rounded-md border ${
409              errors.cvv ? 'border-red-500' : 'border-gray-300'
410            } px-3 py-2`}
411          />
412          {errors.cvv && (
413            <p className="mt-1 text-sm text-red-600">{errors.cvv}</p>
414          )}
415        </div>
416      </div>
417
418      <div className="flex justify-between">
419        <button
420          type="button"
421          onClick={onBack}
422          className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
423        >
424          Retour
425        </button>
426        <button
427          type="submit"
428          className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
429        >
430          Passer la commande
431        </button>
432      </div>
433    </form>
434  );
435}
436
437// Composant de confirmation
438interface ConfirmationProps {
439  data: FormData;
440  onEdit: (step: number) => void;
441}
442
443function Confirmation({ data, onEdit }: ConfirmationProps) {
444  const router = useRouter();
445
446  async function handleConfirm() {
447    try {
448      // Simuler l'envoi à l'API
449      console.log('Envoi de la commande :', data);
450
451      // Exemple d'appel d'API (dans une vraie application)
452      // await fetch('/api/checkout', {
453      //   method: 'POST',
454      //   headers: { 'Content-Type': 'application/json' },
455      //   body: JSON.stringify(data)
456      // });
457
458      // Rediriger vers la page de succès
459      // router.push('/checkout/success');
460
461      // Dans cet exemple, nous affichons simplement une alerte de succès
462      alert('La commande a été passée avec succès !');
463    } catch (error) {
464      console.error('Erreur lors de la commande :', error);
465      alert("Une erreur s'est produite lors de la commande. Réessaie.");
466    }
467  }
468
469  return (
470    <div className="space-y-6">
471      <h2 className="text-xl font-semibold">Confirmation de la commande</h2>
472
473      <div className="border border-gray-200 rounded-lg p-4 space-y-4">
474        <div>
475          <h3 className="font-medium text-gray-700 mb-2">Données personnelles</h3>
476          <div className="bg-gray-50 p-3 rounded">
477            <p>{data.user.firstName} {data.user.lastName}</p>
478            <p>{data.user.email}</p>
479          </div>
480          <button
481            type="button"
482            onClick={() => onEdit(1)}
483            className="text-sm text-blue-500 mt-1"
484          >
485            Modifier
486          </button>
487        </div>
488
489        <div>
490          <h3 className="font-medium text-gray-700 mb-2">Adresse de livraison</h3>
491          <div className="bg-gray-50 p-3 rounded">
492            <p>{data.address.street}</p>
493            <p>{data.address.zipCode} {data.address.city}</p>
494            <p>{data.address.country}</p>
495          </div>
496          <button
497            type="button"
498            onClick={() => onEdit(2)}
499            className="text-sm text-blue-500 mt-1"
500          >
501            Modifier
502          </button>
503        </div>
504
505        <div>
506          <h3 className="font-medium text-gray-700 mb-2">Mode de paiement</h3>
507          <div className="bg-gray-50 p-3 rounded">
508            <p>Carte : **** **** **** {data.payment.cardNumber.slice(-4)}</p>
509            <p>Valable jusqu'au : {data.payment.expiryDate}</p>
510          </div>
511          <button
512            type="button"
513            onClick={() => onEdit(3)}
514            className="text-sm text-blue-500 mt-1"
515          >
516            Modifier
517          </button>
518        </div>
519      </div>
520
521      <div className="flex justify-center">
522        <button
523          type="button"
524          onClick={handleConfirm}
525          className="px-6 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
526        >
527          Confirmer et passer la commande
528        </button>
529      </div>
530    </div>
531  );
532}
533
534// Composant principal du formulaire multi-étapes
535export default function MultiStepCheckout() {
536  const [step, setStep] = useState(1);
537  const [formData, setFormData] = useState<FormData>({
538    user: {
539      firstName: '',
540      lastName: '',
541      email: ''
542    },
543    address: {
544      street: '',
545      city: '',
546      zipCode: '',
547      country: ''
548    },
549    payment: {
550      cardNumber: '',
551      expiryDate: '',
552      cvv: ''
553    }
554  });
555
556  function handleUserSubmit(userData: UserFormData) {
557    setFormData({ ...formData, user: userData });
558    setStep(2);
559  }
560
561  function handleAddressSubmit(addressData: AddressFormData) {
562    setFormData({ ...formData, address: addressData });
563    setStep(3);
564  }
565
566  function handlePaymentSubmit(paymentData: PaymentFormData) {
567    setFormData({ ...formData, payment: paymentData });
568    setStep(4);
569  }
570
571  function renderProgressBar() {
572    return (
573      <div className="mb-8">
574        <div className="flex items-center justify-between mb-4">
575          {[1, 2, 3, 4].map((num) => (
576            <div key={num} className="flex items-center">
577              <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
578                step >= num ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
579              }`}>
580                {num}
581              </div>
582              {num < 4 && (
583                <div className={`h-1 w-12 ${step > num ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
584              )}
585            </div>
586          ))}
587        </div>
588        <div className="flex justify-between text-sm">
589          <span className={step >= 1 ? 'text-blue-500 font-medium' : ''}>Données personnelles</span>
590          <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adresse</span>
591          <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Paiement</span>
592          <span className={step >= 4 ? 'text-blue-500 font-medium' : ''}>Confirmation</span>
593        </div>
594      </div>
595    );
596  }
597
598  function renderStep() {
599    switch (step) {
600      case 1:
601        return <UserForm data={formData.user} onSubmit={handleUserSubmit} />;
602      case 2:
603        return (
604          <AddressForm
605            data={formData.address}
606            onSubmit={handleAddressSubmit}
607            onBack={() => setStep(1)}
608          />
609        );
610      case 3:
611        return (
612          <PaymentForm
613            data={formData.payment}
614            onSubmit={handlePaymentSubmit}
615            onBack={() => setStep(2)}
616          />
617        );
618      case 4:
619        return <Confirmation data={formData} onEdit={setStep} />;
620      default:
621        return null;
622    }
623  }
624
625  return (
626    <div className="max-w-2xl mx-auto px-4 py-8">
627      {renderProgressBar()}
628
629      <div className="bg-white shadow-md rounded-lg p-6">
630        {renderStep()}
631      </div>
632    </div>
633  );
634}

Avantages de cette approche :

  • Meilleure organisation du code
  • Chaque étape est un composant séparé et indépendant
  • Validation plus facile pour chaque étape
  • Les composants peuvent être réutilisés ailleurs
  • Tests plus faciles de chaque étape

3. Formulaires multi-étapes avec les Server Actions en Next.js

Pour les applications Next.js, nous pouvons utiliser les Server Actions pour gérer les données du formulaire :

1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { submitUserData, submitAddressData, submitPaymentData, completeOrder } from './actions';
6
7// Types et composants de formulaire similaires à l'exemple précédent
8//
9export default function CheckoutPage() {
10  const [step, setStep] = useState(1);
11  const [userId, setUserId] = useState<string | null>(null);
12  const [formData, setFormData] = useState({
13    user: { /* données utilisateur */ },
14    address: { /* adresse */ },
15    payment: { /* paiement */ }
16  });
17
18  async function handleUserSubmit(userData: UserFormData) {
19    try {
20      // Sauvegarder les données de l'utilisateur via une Server Action
21      const result = await submitUserData(userData);
22
23      if (result.success) {
24        setUserId(result.userId);
25        setFormData({ ...formData, user: userData });
26        setStep(2);
27      } else {
28        // Gestion des erreurs
29        alert(result.error || "Une erreur s'est produite. Réessaie.");
30      }
31    } catch (error) {
32      console.error("Erreur lors de l'envoi des données utilisateur :", error);
33      alert("Une erreur inattendue s'est produite. Réessaie.");
34    }
35  }
36
37  async function handleAddressSubmit(addressData: AddressFormData) {
38    if (!userId) return;
39
40    try {
41      const result = await submitAddressData(userId, addressData);
42
43      if (result.success) {
44        setFormData({ ...formData, address: addressData });
45        setStep(3);
46      } else {
47        alert(result.error || "Une erreur s'est produite. Réessaie.");
48      }
49    } catch (error) {
50      console.error("Erreur lors de l'envoi des données d'adresse :", error);
51      alert("Une erreur inattendue s'est produite. Réessaie.");
52    }
53  }
54
55  // Fonctions similaires pour les étapes restantes
56  //
57  // Rendre les composants de formulaire pour chaque étape, comme précédemment
58  //
59}

Voici un exemple d'implémentation des Server Actions :

1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schémas de validation pour les formulaires
7const UserSchema = z.object({
8  firstName: z.string().min(1, { message: 'Le prénom est requis' }),
9  lastName: z.string().min(1, { message: 'Le nom de famille est requis' }),
10  email: z.string().email({ message: 'Adresse email invalide' })
11});
12
13const AddressSchema = z.object({
14  street: z.string().min(1, { message: 'La rue est requise' }),
15  city: z.string().min(1, { message: 'La ville est requise' }),
16  zipCode: z.string().min(1, { message: 'Le code postal est requis' }),
17  country: z.string().min(1, { message: 'Le pays est requis' })
18});
19
20// Types de réponse du serveur
21type ServerResponse<T> =
22  | { success: true; data: T }
23  | { success: false; error: string };
24
25export async function submitUserData(formData: unknown): Promise<ServerResponse<{ userId: string }>> {
26  try {
27    // Valider les données
28    const validatedData = UserSchema.safeParse(formData);
29
30    if (!validatedData.success) {
31      return {
32        success: false,
33        error: validatedData.error.errors[0]?.message || 'Données invalides'
34      };
35    }
36
37    // Dans une vraie application : sauvegarder les données en base de données
38    // const user = await db.user.create({ data: validatedData.data });
39
40    // Simulation de la création de l'utilisateur
41    console.log('Données utilisateur sauvegardées :', validatedData.data);
42
43    return {
44      success: true,
45      data: { userId: 'user_123' }
46    };
47  } catch (error) {
48    console.error("Erreur serveur lors de l'envoi des données utilisateur :", error);
49    return {
50      success: false,
51      error: "Une erreur s'est produite lors du traitement des données"
52    };
53  }
54}
55
56export async function submitAddressData(
57  userId: string,
58  formData: unknown
59): Promise<ServerResponse<{ addressId: string }>> {
60  try {
61    // Valider les données
62    const validatedData = AddressSchema.safeParse(formData);
63
64    if (!validatedData.success) {
65      return {
66        success: false,
67        error: validatedData.error.errors[0]?.message || "Données d'adresse invalides"
68      };
69    }
70
71    // Dans une vraie application : sauvegarder l'adresse en base de données
72    // const address = await db.address.create({
73    //   data: { ...validatedData.data, userId }
74    // });
75
76    // Simulation de la sauvegarde de l'adresse
77    console.log("Adresse sauvegardée pour l'utilisateur", userId, ':', validatedData.data);
78
79    return {
80      success: true,
81      data: { addressId: 'addr_456' }
82    };
83  } catch (error) {
84    console.error("Erreur serveur lors de l'envoi des données d'adresse :", error);
85    return {
86      success: false,
87      error: "Une erreur s'est produite lors du traitement de l'adresse"
88    };
89  }
90}
91
92// Fonctions similaires pour le paiement et la finalisation de la commande
93//

Avantages de cette approche :

  • Validation et traitement des données sur le serveur
  • Possibilité de sauvegarder les données en base de données de façon incrémentale
  • Meilleure protection des données sensibles (par ex. les détails de paiement)
  • Prise en charge de l'amélioration progressive

Gestion de l'état dans les formulaires multi-étapes

Pour les formulaires plus complexes, il est utile d'envisager des bibliothèques de gestion de l'état des formulaires :

Gestion de l'état avec Formik ou React Hook Form

1// Exemple avec React Hook Form
2import { useForm, FormProvider, useFormContext } from 'react-hook-form';
3import { zodResolver } from '@hookform/resolvers/zod';
4import { z } from 'zod';
5
6// Validation schema
7const CheckoutSchema = z.object({
8  user: z.object({
9    firstName: z.string().min(1, 'Le prénom est requis'),
10    lastName: z.string().min(1, 'Le nom de famille est requis'),
11    email: z.string().email('Adresse email invalide')
12  }),
13  address: z.object({
14    street: z.string().min(1, 'La rue est requise'),
15    city: z.string().min(1, 'La ville est requise'),
16    zipCode: z.string().min(1, 'Le code postal est requis'),
17    country: z.string().min(1, 'Le pays est requis')
18  }),
19  payment: z.object({
20    cardNumber: z.string().refine(val => /^\d{16}$/.test(val.replace(/\s/g, '')), {
21      message: 'Numéro de carte invalide'
22    }),
23    expiryDate: z.string().min(1, "La date d'expiration est requise"),
24    cvv: z.string().refine(val => /^\d{3,4}$/.test(val), {
25      message: 'Code CVV invalide'
26    })
27  })
28});
29
30type CheckoutData = z.infer<typeof CheckoutSchema>;
31
32function UserFormStep() {
33  const { register, formState: { errors } } = useFormContext<CheckoutData>();
34
35  return (
36    <div className="space-y-4">
37      <h2 className="text-xl font-semibold">Données personnelles</h2>
38
39      <div>
40        <label htmlFor="firstName" className="block text-sm font-medium">First name</label>
41        <input
42          id="firstName"
43          {...register('user.firstName')}
44          className={`mt-1 block w-full rounded-md border ${
45            errors.user?.firstName ? 'border-red-500' : 'border-gray-300'
46          } px-3 py-2`}
47        />
48        {errors.user?.firstName && (
49          <p className="mt-1 text-sm text-red-600">{errors.user.firstName.message}</p>
50        )}
51      </div>
52
53      {/* Champs restants... */}
54    </div>
55  );
56}
57
58// Définir de la même manière les étapes restantes du formulaire
59//
60export default function CheckoutForm() {
61  const [step, setStep] = useState(1);
62
63  const methods = useForm<CheckoutData>({
64    resolver: zodResolver(CheckoutSchema),
65    mode: 'onBlur',
66    defaultValues: {
67      user: { firstName: '', lastName: '', email: '' },
68      address: { street: '', city: '', zipCode: '', country: '' },
69      payment: { cardNumber: '', expiryDate: '', cvv: '' }
70    }
71  });
72
73  function onSubmit(data: CheckoutData) {
74    console.log('Envoi des données :', data);
75    // Logique d'envoi des données à l'API
76  }
77
78  const nextStep = () => {
79    if (step < 3) {
80      setStep(step + 1);
81    } else {
82      methods.handleSubmit(onSubmit)();
83    }
84  };
85
86  const prevStep = () => setStep(step - 1);
87
88  return (
89    <FormProvider {...methods}>
90      <form onSubmit={methods.handleSubmit(onSubmit)}>
91        {/* Barre de progression */}
92
93        {/* Rendre l'étape appropriée */}
94        {step === 1 && <UserFormStep />}
95        {step === 2 && <AddressFormStep />}
96        {step === 3 && <PaymentFormStep />}
97
98        {/* Boutons de navigation */}
99        <div className="mt-6 flex justify-between">
100          {step > 1 && (
101            <button
102              type="button"
103              onClick={prevStep}
104              className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md"
105            >
106              Retour
107            </button>
108          )}
109
110          <button
111            type="button"
112            onClick={nextStep}
113            className={`px-4 py-2 ${
114              step === 3 ? 'bg-green-500 hover:bg-green-600' : 'bg-blue-500 hover:bg-blue-600'
115            } text-white rounded-md`}
116          >
117            {step === 3 ? 'Passer la commande' : 'Suivant'}
118          </button>
119        </div>
120      </form>
121    </FormProvider>
122  );
123}

Gestion de l'état avec React Context

Pour les formulaires très complexes, il peut être avantageux d'utiliser l'API React Context :

1// context/CheckoutContext.tsx
2import { createContext, useContext, useReducer, ReactNode } from 'react';
3
4// Types de données du formulaire
5//
6// État du formulaire
7interface CheckoutState {
8  step: number;
9  user: UserFormData;
10  address: AddressFormData;
11  payment: PaymentFormData;
12  errors: Record<string, string>;
13}
14
15// Types d'actions
16type CheckoutAction =
17  | { type: 'SET_STEP'; payload: number }
18  | { type: 'UPDATE_USER'; payload: Partial<UserFormData> }
19  | { type: 'UPDATE_ADDRESS'; payload: Partial<AddressFormData> }
20  | { type: 'UPDATE_PAYMENT'; payload: Partial<PaymentFormData> }
21  | { type: 'SET_ERRORS'; payload: Record<string, string> }
22  | { type: 'CLEAR_ERRORS' };
23
24// État initial
25const initialState: CheckoutState = {
26  step: 1,
27  user: { firstName: '', lastName: '', email: '' },
28  address: { street: '', city: '', zipCode: '', country: '' },
29  payment: { cardNumber: '', expiryDate: '', cvv: '' },
30  errors: {}
31};
32
33// Reducer
34function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
35  switch (action.type) {
36    case 'SET_STEP':
37      return { ...state, step: action.payload };
38    case 'UPDATE_USER':
39      return { ...state, user: { ...state.user, ...action.payload } };
40    case 'UPDATE_ADDRESS':
41      return { ...state, address: { ...state.address, ...action.payload } };
42    case 'UPDATE_PAYMENT':
43      return { ...state, payment: { ...state.payment, ...action.payload } };
44    case 'SET_ERRORS':
45      return { ...state, errors: action.payload };
46    case 'CLEAR_ERRORS':
47      return { ...state, errors: {} };
48    default:
49      return state;
50  }
51}
52
53// Type du contexte
54interface CheckoutContextType {
55  state: CheckoutState;
56  nextStep: () => void;
57  prevStep: () => void;
58  updateUser: (data: Partial<UserFormData>) => void;
59  updateAddress: (data: Partial<AddressFormData>) => void;
60  updatePayment: (data: Partial<PaymentFormData>) => void;
61  validateStep: (step: number) => boolean;
62  submitOrder: () => Promise<void>;
63}
64
65// Contexte
66const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined);
67
68// Provider
69export function CheckoutProvider({ children }: { children: ReactNode }) {
70  const [state, dispatch] = useReducer(checkoutReducer, initialState);
71
72  // Fonctions utilitaires
73  const nextStep = () => {
74    if (validateStep(state.step)) {
75      dispatch({ type: 'SET_STEP', payload: state.step + 1 });
76    }
77  };
78
79  const prevStep = () => {
80    dispatch({ type: 'SET_STEP', payload: state.step - 1 });
81  };
82
83  const updateUser = (data: Partial<UserFormData>) => {
84    dispatch({ type: 'UPDATE_USER', payload: data });
85  };
86
87  const updateAddress = (data: Partial<AddressFormData>) => {
88    dispatch({ type: 'UPDATE_ADDRESS', payload: data });
89  };
90
91  const updatePayment = (data: Partial<PaymentFormData>) => {
92    dispatch({ type: 'UPDATE_PAYMENT', payload: data });
93  };
94
95  // Validation de l'étape
96  const validateStep = (step: number): boolean => {
97    // Logique de validation...
98    return true; // Simplifié pour l'exemple
99  };
100
101  // Envoi de la commande
102  const submitOrder = async () => {
103    if (validateStep(state.step)) {
104      try {
105        // Logique d'envoi des données
106        console.log('Envoi des données :', state);
107      } catch (error) {
108        console.error("Erreur lors de l'envoi de la commande :", error);
109      }
110    }
111  };
112
113  return (
114    <CheckoutContext.Provider
115      value={{
116        state,
117        nextStep,
118        prevStep,
119        updateUser,
120        updateAddress,
121        updatePayment,
122        validateStep,
123        submitOrder
124      }}
125    >
126      {children}
127    </CheckoutContext.Provider>
128  );
129}
130
131// Hook pour utiliser le contexte
132export function useCheckout() {
133  const context = useContext(CheckoutContext);
134  if (context === undefined) {
135    throw new Error('useCheckout must be used within a CheckoutProvider');
136  }
137  return context;
138}

Techniques avancées pour les formulaires multi-étapes

1. Parcours de formulaire conditionnels

Parfois, nous voulons que les utilisateurs suivent différents parcours de formulaire selon leurs choix :

1// Exemple d'implémentation de parcours conditionnels
2function determineNextStep(currentStep: number, formData: FormData): number {
3  if (currentStep === 1) {
4    // Si l'utilisateur a choisi la livraison à une autre adresse, ajouter l'étape de l'adresse de livraison
5    if (formData.shippingDifferent) {
6      return 2; // Étape de l'adresse de livraison
7    } else {
8      return 3; // Sauter l'étape de l'adresse de livraison, aller au paiement
9    }
10  }
11
12  if (currentStep === 3) {
13    // Si l'utilisateur a choisi le paiement par virement, sauter l'étape des détails de la carte
14    if (formData.paymentMethod === 'bankTransfer') {
15      return 5; // Aller au récapitulatif
16    } else {
17      return 4; // Étape des détails de la carte
18    }
19  }
20
21  // Transition par défaut vers l'étape suivante
22  return currentStep + 1;
23}
24
25// Utilisation dans un composant
26function handleNext() {
27  const isValid = validateCurrentStep();
28
29  if (isValid) {
30    const nextStep = determineNextStep(step, formData);
31    setStep(nextStep);
32  }
33}

2. Sauvegarder et restaurer l'état du formulaire

Pour les longs formulaires, il est utile de permettre la sauvegarde de la progression :

1'use client';
2
3import { useState, useEffect } from 'react';
4
5// Sauvegarder l'état du formulaire
6function saveFormState(data: FormData) {
7  localStorage.setItem('checkout_form', JSON.stringify(data));
8  localStorage.setItem('checkout_timestamp', Date.now().toString());
9}
10
11// Restaurer l'état du formulaire
12function getSavedFormState(): FormData | null {
13  const savedData = localStorage.getItem('checkout_form');
14  const timestamp = localStorage.getItem('checkout_timestamp');
15
16  if (!savedData || !timestamp) {
17    return null;
18  }
19
20  // Vérifier que les données sauvegardées ne sont pas trop anciennes (par ex. 24 heures)
21  const savedTime = parseInt(timestamp, 10);
22  const now = Date.now();
23  const oneDayMs = 24 * 60 * 60 * 1000;
24
25  if (now - savedTime > oneDayMs) {
26    // Les données sont trop anciennes, les supprimer
27    localStorage.removeItem('checkout_form');
28    localStorage.removeItem('checkout_timestamp');
29    return null;
30  }
31
32  try {
33    return JSON.parse(savedData) as FormData;
34  } catch (e) {
35    return null;
36  }
37}
38
39export default function CheckoutForm() {
40  const [formData, setFormData] = useState<FormData>({
41    // État par défaut du formulaire
42  });
43
44  const [step, setStep] = useState(1);
45  const [hasRestoredState, setHasRestoredState] = useState(false);
46
47  // Tenter de restaurer l'état du formulaire au premier rendu
48  useEffect(() => {
49    if (!hasRestoredState) {
50      const savedState = getSavedFormState();
51
52      if (savedState) {
53        setFormData(savedState);
54        setHasRestoredState(true);
55
56        // Facultatif : demander à l'utilisateur s'il veut continuer là où il s'était arrêté
57        const wishToContinue = window.confirm(
58          "Un formulaire incomplet d'une session précédente a été trouvé. Veux-tu continuer là où tu t'étais arrêté ?"
59        );
60
61        if (wishToContinue) {
62          // Nous pouvons déterminer à quelle étape l'utilisateur s'est arrêté
63          // par ex. selon les données déjà renseignées
64          if (savedState.payment.cardNumber) {
65            setStep(3);
66          } else if (savedState.address.street) {
67            setStep(2);
68          } else {
69            setStep(1);
70          }
71        } else {
72          // L'utilisateur ne veut pas continuer, effacer les données sauvegardées
73          localStorage.removeItem('checkout_form');
74          localStorage.removeItem('checkout_timestamp');
75          setFormData({
76            // Réinitialiser à l'état par défaut
77          });
78        }
79      }
80
81      setHasRestoredState(true);
82    }
83  }, [hasRestoredState]);
84
85  // Sauvegarder l'état du formulaire on changes
86  useEffect(() => {
87    if (hasRestoredState) {
88      saveFormState(formData);
89    }
90  }, [formData, hasRestoredState]);
91
92  // Reste du composant...
93}

3. Ajouter et supprimer dynamiquement des champs de formulaire

Dans certains formulaires multi-étapes, les utilisateurs peuvent avoir besoin d'ajouter ou de supprimer dynamiquement des éléments :

1'use client';
2
3import { useState } from 'react';
4
5interface Item {
6  id: string;
7  name: string;
8  quantity: number;
9}
10
11function OrderItemsStep() {
12  const [items, setItems] = useState<Item[]>([
13    { id: '1', name: '', quantity: 1 }
14  ]);
15
16  const addItem = () => {
17    const newId = (parseInt(items[items.length - 1].id) + 1).toString();
18    setItems([...items, { id: newId, name: '', quantity: 1 }]);
19  };
20
21  const removeItem = (id: string) => {
22    // Ne pas supprimer le dernier élément
23    if (items.length <= 1) return;
24
25    setItems(items.filter(item => item.id !== id));
26  };
27
28  const updateItem = (id: string, field: keyof Item, value: string | number) => {
29    setItems(items.map(item =>
30      item.id === id ? { ...item, [field]: value } : item
31    ));
32  };
33
34  return (
35    <div className="space-y-4">
36      <h2 className="text-xl font-semibold">Articles de la commande</h2>
37
38      {items.map(item => (
39        <div key={item.id} className="flex items-center space-x-4 p-4 border rounded-md">
40          <div className="grow">
41            <label htmlFor={`item-name-${item.id}`} className="block text-sm font-medium">
42              Nom du produit
43            </label>
44            <input
45              id={`item-name-${item.id}`}
46              type="text"
47              value={item.name}
48              onChange={(e) => updateItem(item.id, 'name', e.target.value)}
49              className="mt-1 block w-full rounded-md border-gray-300 px-3 py-2"
50            />
51          </div>
52
53          <div className="w-24">
54            <label htmlFor={`item-qty-${item.id}`} className="block text-sm font-medium">
55              Quantité
56            </label>
57            <input
58              id={`item-qty-${item.id}`}
59              type="number"
60              min="1"
61              value={item.quantity}
62              onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value))}
63              className="mt-1 block w-full rounded-md border-gray-300 px-3 py-2"
64            />
65          </div>
66
67          <button
68            type="button"
69            onClick={() => removeItem(item.id)}
70            className="mt-6 px-2 py-2 bg-red-100 text-red-600 rounded-md hover:bg-red-200"
71            aria-label="Supprimer l'article"
72          >
73            &times;
74          </button>
75        </div>
76      ))}
77
78      <button
79        type="button"
80        onClick={addItem}
81        className="w-full mt-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
82      >
83        + Ajouter d'autres produits
84      </button>
85    </div>
86  );
87}

Bonnes pratiques pour les formulaires multi-étapes

  1. Montre la progression - indique à l'utilisateur où il en est dans le processus (barre de progression, numérotation des étapes)

  2. Préserve les données - les utilisateurs ne doivent pas perdre les données saisies quand la page est rafraîchie ou fermée par accident

  3. Permets la navigation entre les étapes - permets aux utilisateurs de revenir aux étapes précédentes et de modifier les données

  4. Valide chaque étape - vérifie l'exactitude des données avant de passer à l'étape suivante

  5. Fournis des messages d'erreur clairs - indique à l'utilisateur exactement ce qui a mal tourné

  6. Permets de sauvegarder et de revenir plus tard - surtout pour les longs formulaires

  7. Adapte les étapes à l'appareil - sur les petits écrans, envisage des étapes encore plus courtes

  8. Fournis un récapitulatif avant la soumission finale - donne aux utilisateurs un aperçu de toutes les données saisies

  9. Gère les boutons précédent/suivant du navigateur - adapte l'application pour fonctionner avec les boutons précédent/suivant du navigateur

  10. Teste divers scénarios - assure-toi que le formulaire fonctionne correctement à la fois pour un remplissage standard et dans des scénarios comme :

    • Le retour aux étapes précédentes
    • Le rafraîchissement de la page
    • La tentative de soumission de données incomplètes
    • La perte de connexion internet

Drizzle ORM - une alternative légère à Prisma

Dans la Métropole Quantique de l'an 2150, où chaque cycle de processeur compte, les développeurs recherchent souvent des outils à la fois puissants et légers. Drizzle ORM est une alternative moderne à Prisma qui offre la sûreté des types tout en restant proche du SQL pur.

Drizzle vs Prisma - comparaison

Drizzle et Prisma sont tous deux des ORM à typage sûr pour TypeScript, mais ils diffèrent par leur philosophie et leur approche :

AspectDrizzle ORMPrisma
PhilosophieProche du SQL, au plus près de la machineL'abstraction d'abord, API conviviale
Taille~20 Ko~600 Ko+
MigrationsSQL ou TypeScriptLangage DSL personnalisé
RequêtesProches du SQLAPI personnalisée
PerformancesTrès rapidesBonnes
Courbe d'apprentissageDouce pour les utilisateurs de SQLDouce pour les débutants
Sûreté des typesComplèteComplète
Edge RuntimeSupport natifNécessite Data Proxy

Pourquoi Drizzle est plus léger et plus proche du SQL

Drizzle est une « fine couche TypeScript » au-dessus du SQL, ce qui signifie :

  1. Abstraction minimale - les requêtes ressemblent à du SQL, donc faciles à optimiser
  2. Petite taille de bundle - le code de Drizzle est compact
  3. Aucun surcoût à l'exécution - tous les types sont supprimés à la compilation
  4. Contrôle total - tu peux utiliser du SQL brut quand tu en as besoin
1// Drizzle - ressemble à du SQL
2const result = await db
3  .select()
4  .from(users)
5  .where(eq(users.email, 'user@example.com'))
6  .limit(1);
7
8// Prisma - plus abstrait
9const result = await prisma.user.findUnique({
10  where: { email: 'user@example.com' }
11});

Requêtes à typage sûr avec Drizzle

Drizzle offre un support complet de TypeScript, détectant les erreurs pendant que tu écris le code :

Définition du schéma

1// db/schema.ts
2import { pgTable, serial, text, varchar, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
3import { relations } from 'drizzle-orm';
4
5// Table des utilisateurs
6export const users = pgTable('users', {
7  id: serial('id').primaryKey(),
8  name: varchar('name', { length: 255 }).notNull(),
9  email: varchar('email', { length: 255 }).notNull().unique(),
10  password: text('password').notNull(),
11  createdAt: timestamp('created_at').defaultNow().notNull(),
12  updatedAt: timestamp('updated_at').defaultNow().notNull()
13});
14
15// Table des articles
16export const posts = pgTable('posts', {
17  id: serial('id').primaryKey(),
18  title: varchar('title', { length: 255 }).notNull(),
19  content: text('content').notNull(),
20  published: boolean('published').default(false).notNull(),
21  authorId: integer('author_id')
22    .notNull()
23    .references(() => users.id, { onDelete: 'cascade' }),
24  createdAt: timestamp('created_at').defaultNow().notNull(),
25  updatedAt: timestamp('updated_at').defaultNow().notNull()
26});
27
28// Table des commentaires
29export const comments = pgTable('comments', {
30  id: serial('id').primaryKey(),
31  content: text('content').notNull(),
32  postId: integer('post_id')
33    .notNull()
34    .references(() => posts.id, { onDelete: 'cascade' }),
35  authorId: integer('author_id')
36    .notNull()
37    .references(() => users.id, { onDelete: 'cascade' }),
38  createdAt: timestamp('created_at').defaultNow().notNull()
39});
40
41// Relations entre les tables
42export const usersRelations = relations(users, ({ many }) => ({
43  posts: many(posts),
44  comments: many(comments)
45}));
46
47export const postsRelations = relations(posts, ({ one, many }) => ({
48  author: one(users, {
49    fields: [posts.authorId],
50    references: [users.id]
51  }),
52  comments: many(comments)
53}));
54
55export const commentsRelations = relations(comments, ({ one }) => ({
56  post: one(posts, {
57    fields: [comments.postId],
58    references: [posts.id]
59  }),
60  author: one(users, {
61    fields: [comments.authorId],
62    references: [users.id]
63  })
64}));
65
66// Types TypeScript générés automatiquement
67export type User = typeof users.$inferSelect;
68export type NewUser = typeof users.$inferInsert;
69
70export type Post = typeof posts.$inferSelect;
71export type NewPost = typeof posts.$inferInsert;
72
73export type Comment = typeof comments.$inferSelect;
74export type NewComment = typeof comments.$inferInsert;

Configuration de la connexion

1// db/index.ts
2import { drizzle } from 'drizzle-orm/postgres-js';
3import postgres from 'postgres';
4import * as schema from './schema';
5
6// Client PostgreSQL
7const connectionString = process.env.DATABASE_URL!;
8const client = postgres(connectionString);
9
10// Instance Drizzle
11export const db = drizzle(client, { schema });
12
13// Pour l'Edge Runtime (Vercel, Cloudflare Workers)
14// import { drizzle } from 'drizzle-orm/vercel-postgres';
15// import { sql } from '@vercel/postgres';
16// export const db = drizzle(sql, { schema });

Requêtes à typage sûr

1// app/actions/users.ts
2'use server';
3
4import { db } from '@/db';
5import { users, posts } from '@/db/schema';
6import { eq, and, or, like, desc, count } from 'drizzle-orm';
7
8// SELECT - récupération des données
9export async function getUser(id: number) {
10  const [user] = await db
11    .select()
12    .from(users)
13    .where(eq(users.id, id))
14    .limit(1);
15
16  return user;
17}
18
19// SELECT avec plusieurs conditions
20export async function searchUsers(query: string) {
21  return await db
22    .select({
23      id: users.id,
24      name: users.name,
25      email: users.email
26    })
27    .from(users)
28    .where(
29      or(
30        like(users.name, `%${query}%`),
31        like(users.email, `%${query}%`)
32      )
33    )
34    .limit(10);
35}
36
37// INSERT - création d'un utilisateur
38export async function createUser(data: {
39  name: string;
40  email: string;
41  password: string;
42}) {
43  const [newUser] = await db
44    .insert(users)
45    .values(data)
46    .returning(); // PostgreSQL specific
47
48  return newUser;
49}
50
51// UPDATE - mise à jour d'un utilisateur
52export async function updateUser(
53  id: number,
54  data: Partial<{ name: string; email: string }>
55) {
56  const [updated] = await db
57    .update(users)
58    .set({
59      ...data,
60      updatedAt: new Date()
61    })
62    .where(eq(users.id, id))
63    .returning();
64
65  return updated;
66}
67
68// DELETE - suppression d'un utilisateur
69export async function deleteUser(id: number) {
70  await db
71    .delete(users)
72    .where(eq(users.id, id));
73}
74
75// JOIN - récupération d'un utilisateur avec ses articles
76export async function getUserWithPosts(userId: number) {
77  return await db.query.users.findFirst({
78    where: eq(users.id, userId),
79    with: {
80      posts: {
81        orderBy: [desc(posts.createdAt)],
82        limit: 10
83      }
84    }
85  });
86}
87
88// Agrégations
89export async function getPostsCount(userId: number) {
90  const [result] = await db
91    .select({ count: count() })
92    .from(posts)
93    .where(eq(posts.authorId, userId));
94
95  return result.count;
96}
97
98// Transactions
99export async function createUserWithPost(
100  userData: { name: string; email: string; password: string },
101  postData: { title: string; content: string }
102) {
103  return await db.transaction(async (tx) => {
104    // Créer l'utilisateur
105    const [user] = await tx
106      .insert(users)
107      .values(userData)
108      .returning();
109
110    // Créer un article pour cet utilisateur
111    const [post] = await tx
112      .insert(posts)
113      .values({
114        ...postData,
115        authorId: user.id,
116        published: false
117      })
118      .returning();
119
120    return { user, post };
121  });
122}

Requêtes avancées

Requêtes relationnelles (Relational Queries API)

1// La façon la plus simple de faire des JOIN
2export async function getPostsWithAuthors() {
3  return await db.query.posts.findMany({
4    with: {
5      author: true,
6      comments: {
7        with: {
8          author: true
9        }
10      }
11    },
12    orderBy: [desc(posts.createdAt)],
13    limit: 20
14  });
15}
16
17// Sélection partielle avec relations
18export async function getPostsPartial() {
19  return await db.query.posts.findMany({
20    columns: {
21      id: true,
22      title: true,
23      createdAt: true
24    },
25    with: {
26      author: {
27        columns: {
28          id: true,
29          name: true
30        }
31      }
32    }
33  });
34}
35
36// Filtrage au sein des relations
37export async function getPublishedPostsWithComments() {
38  return await db.query.posts.findMany({
39    where: eq(posts.published, true),
40    with: {
41      comments: {
42        where: (comments, { gte }) =>
43          gte(comments.createdAt, new Date('2024-01-01')),
44        limit: 5,
45        orderBy: [desc(comments.createdAt)]
46      }
47    }
48  });
49}

SQL brut quand tu en as besoin

1import { sql } from 'drizzle-orm';
2
3// SQL brut pour les requêtes avancées
4export async function complexAnalytics() {
5  const result = await db.execute(sql`
6    SELECT
7      u.id,
8      u.name,
9      COUNT(DISTINCT p.id) as posts_count,
10      COUNT(DISTINCT c.id) as comments_count,
11      MAX(p.created_at) as last_post_date
12    FROM users u
13    LEFT JOIN posts p ON p.author_id = u.id
14    LEFT JOIN comments c ON c.author_id = u.id
15    WHERE u.created_at > NOW() - INTERVAL '30 days'
16    GROUP BY u.id, u.name
17    ORDER BY posts_count DESC
18    LIMIT 10
19  `);
20
21  return result.rows;
22}
23
24// Combiner des requêtes à typage sûr avec du SQL brut
25export async function hybridQuery(minPosts: number) {
26  return await db
27    .select({
28      userId: users.id,
29      userName: users.name,
30      postsCount: sql<number>`COUNT(${posts.id})`
31    })
32    .from(users)
33    .leftJoin(posts, eq(posts.authorId, users.id))
34    .groupBy(users.id)
35    .having(sql`COUNT(${posts.id}) > ${minPosts}`);
36}

Migrations dans Drizzle

Drizzle propose deux façons de gérer les migrations :

1. Génération SQL automatique

1// drizzle.config.ts
2import type { Config } from 'drizzle-kit';
3
4export default {
5  schema: './db/schema.ts',
6  out: './drizzle',
7  driver: 'pg',
8  dbCredentials: {
9    connectionString: process.env.DATABASE_URL!
10  }
11} satisfies Config;
1# Générer les migrations
2npx drizzle-kit generate:pg
3
4# Exécuter les migrations
5npx drizzle-kit push:pg
6
7# Ou à l'aide d'un script personnalisé
8# db/migrate.ts
9import { drizzle } from 'drizzle-orm/postgres-js';
10import { migrate } from 'drizzle-orm/postgres-js/migrator';
11import postgres from 'postgres';
12
13const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
14const db = drizzle(sql);
15
16await migrate(db, { migrationsFolder: './drizzle' });
17await sql.end();

2. Migrations SQL manuelles

1-- drizzle/0001_initial.sql
2CREATE TABLE IF NOT EXISTS "users" (
3  "id" serial PRIMARY KEY NOT NULL,
4  "name" varchar(255) NOT NULL,
5  "email" varchar(255) NOT NULL,
6  "password" text NOT NULL,
7  "created_at" timestamp DEFAULT now() NOT NULL,
8  "updated_at" timestamp DEFAULT now() NOT NULL,
9  CONSTRAINT "users_email_unique" UNIQUE("email")
10);
11
12CREATE TABLE IF NOT EXISTS "posts" (
13  "id" serial PRIMARY KEY NOT NULL,
14  "title" varchar(255) NOT NULL,
15  "content" text NOT NULL,
16  "published" boolean DEFAULT false NOT NULL,
17  "author_id" integer NOT NULL,
18  "created_at" timestamp DEFAULT now() NOT NULL,
19  "updated_at" timestamp DEFAULT now() NOT NULL,
20  CONSTRAINT "posts_author_id_users_id_fk"
21    FOREIGN KEY ("author_id") REFERENCES "users"("id")
22    ON DELETE cascade
23);

Intégration avec l'App Router de Next.js

1// app/users/[id]/page.tsx
2import { db } from '@/db';
3import { users, posts } from '@/db/schema';
4import { eq, desc } from 'drizzle-orm';
5
6export default async function UserPage({
7  params
8}: {
9  params: Promise<{ id: string }>
10}) {
11  const userId = parseInt((await params).id);
12
13  // Requête exécutée dans un Server Component
14  const user = await db.query.users.findFirst({
15    where: eq(users.id, userId),
16    with: {
17      posts: {
18        orderBy: [desc(posts.createdAt)],
19        limit: 5
20      }
21    }
22  });
23
24  if (!user) {
25    return <div>Utilisateur introuvable</div>;
26  }
27
28  return (
29    <div className="p-8">
30      <h1 className="text-3xl font-bold mb-4">{user.name}</h1>
31      <p className="text-gray-600 mb-8">{user.email}</p>
32
33      <h2 className="text-2xl font-semibold mb-4">Articles</h2>
34      <div className="space-y-4">
35        {user.posts.map(post => (
36          <article key={post.id} className="border p-4 rounded-lg">
37            <h3 className="text-xl font-bold">{post.title}</h3>
38            <p className="text-gray-600 mt-2">{post.content}</p>
39            <time className="text-sm text-gray-400 mt-2 block">
40              {post.createdAt.toLocaleDateString()}
41            </time>
42          </article>
43        ))}
44      </div>
45    </div>
46  );
47}
48
49// app/actions/posts.ts
50'use server';
51
52import { db } from '@/db';
53import { posts } from '@/db/schema';
54import { revalidatePath } from 'next/cache';
55
56export async function createPost(formData: FormData) {
57  const title = formData.get('title') as string;
58  const content = formData.get('content') as string;
59  const authorId = parseInt(formData.get('authorId') as string);
60
61  const [newPost] = await db
62    .insert(posts)
63    .values({
64      title,
65      content,
66      authorId,
67      published: false
68    })
69    .returning();
70
71  revalidatePath(`/users/${authorId}`);
72
73  return { success: true, post: newPost };
74}

Drizzle vs Prisma - lequel choisir et quand ?

Choisis Drizzle lorsque :

  • Tu connais bien le SQL et veux un contrôle total
  • Tu tiens à une petite taille de bundle
  • Tu construis des applications edge
  • Tu as besoin de performances maximales
  • Tu préfères le minimalisme et la simplicité

Choisis Prisma lorsque :

  • Tu es un développeur débutant ou intermédiaire
  • Tu as besoin d'une excellente documentation et d'un bon écosystème
  • Tu veux de l'abstraction et ne veux pas te soucier du SQL
  • Tu utilises Prisma Studio pour gérer les données
  • Ton équipe est plus grande et a besoin de conventions cohérentes

Application pratique

Dans la Métropole Quantique, où les applications doivent être à la fois puissantes et légères, Drizzle offre une excellente alternative. Sa proximité avec le SQL permet aux développeurs expérimentés d'optimiser facilement les requêtes, tandis que le support complet de TypeScript assure la sûreté des types à chaque étape.

1// Exemple réel : tableau de bord avec des analyses
2export async function getDashboardStats(userId: number) {
3  // Tout est à typage sûr, proche du SQL, rapide
4  const stats = await db
5    .select({
6      totalPosts: count(posts.id),
7      publishedPosts: sql<number>`
8        COUNT(CASE WHEN ${posts.published} = true THEN 1 END)
9      `,
10      draftPosts: sql<number>`
11        COUNT(CASE WHEN ${posts.published} = false THEN 1 END)
12      `,
13      totalComments: count(comments.id)
14    })
15    .from(users)
16    .leftJoin(posts, eq(posts.authorId, users.id))
17    .leftJoin(comments, eq(comments.postId, posts.id))
18    .where(eq(users.id, userId))
19    .groupBy(users.id);
20
21  return stats[0];
22}

Résumé

Les formulaires multi-étapes sont un outil puissant pour collecter des données complexes auprès des utilisateurs de façon conviviale et organisée. Il existe de nombreuses stratégies pour les implémenter, des approches simples basées sur l'état du composant, aux approches modulaires avec des composants séparés pour chaque étape, jusqu'aux solutions avancées qui exploitent le contexte React et les bibliothèques de gestion de formulaires.

En React et Next.js, nous disposons d'options flexibles pour implémenter de tels formulaires, avec les avantages supplémentaires offerts par les Server Actions de Next.js pour l'amélioration progressive et la sécurité des données.

La clé du succès est de choisir une approche adaptée à la complexité du formulaire et aux besoins de l'application, tout en offrant aux utilisateurs une expérience fluide et sans friction.

Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3type Step1Data = { firstName: string; lastName: string; email: string };
4type Step2Data = { company: string; role: string; experience: string };
5type Step3Data = { plan: string; newsletter: boolean };
6type AllData = Step1Data & Step2Data & Step3Data;
7
8const TOTAL_STEPS = 3;
9
10function StepIndicator({ current, total }: { current: number; total: number }) {
11  return (
12    <div style={{ display: 'flex', alignItems: 'center', marginBottom: 24 }}>
13      {Array.from({ length: total }, (_, i) => (
14        <React.Fragment key={i}>
15          <div style={{
16            width: 32, height: 32, borderRadius: '50%', display: 'flex',
17            alignItems: 'center', justifyContent: 'center', fontWeight: 'bold',
18            fontSize: 13, fontFamily: 'monospace',
19            background: i + 1 < current ? '#4caf50' : i + 1 === current ? '#1a2744' : '#0d1117',
20            color: i + 1 < current ? '#fff' : i + 1 === current ? '#64ffda' : '#555',
21            border: i + 1 === current ? '2px solid #64ffda' : '2px solid transparent',
22          }}>
23            {i + 1 < current ? '✓' : i + 1}
24          </div>
25          {i < total - 1 && (
26            <div style={{ flex: 1, height: 2, background: i + 1 < current ? '#4caf50' : '#333', margin: '0 6px' }} />
27          )}
28        </React.Fragment>
29      ))}
30    </div>
31  );
32}
33
34const inputStyle: React.CSSProperties = {
35  width: '100%', padding: '10px', background: '#0d1117',
36  border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
37  fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box', marginBottom: 12
38};
39
40function Step1({ data, onChange }: { data: Step1Data; onChange: (d: Partial<Step1Data>) => void }) {
41  return (
42    <div>
43      <h3 style={{ color: '#64ffda', margin: '0 0 12px' }}>Étape 1 : Informations personnelles</h3>
44      <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Prénom *</label>
45      <input value={data.firstName} onChange={e => onChange({ firstName: e.target.value })} placeholder="Jan" style={inputStyle} />
46      <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Nom *</label>
47      <input value={data.lastName} onChange={e => onChange({ lastName: e.target.value })} placeholder="Kowalski" style={inputStyle} />
48      <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Email *</label>
49      <input type="email" value={data.email} onChange={e => onChange({ email: e.target.value })} placeholder="jan@example.com" style={inputStyle} />
50    </div>
51  );
52}
53
54function Step2({ data, onChange }: { data: Step2Data; onChange: (d: Partial<Step2Data>) => void }) {
55  return (
56    <div>
57      <h3 style={{ color: '#64ffda', margin: '0 0 12px' }}>Étape 2 : Informations professionnelles</h3>
58      <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Entreprise</label>
59      <input value={data.company} onChange={e => onChange({ company: e.target.value })} placeholder="Nom de l'entreprise" style={inputStyle} />
60      <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Poste *</label>
61      <select value={data.role} onChange={e => onChange({ role: e.target.value })} style={inputStyle}>
62        <option value="">-- Choisir --</option>
63        <option value="frontend">Frontend Developer</option>
64        <option value="backend">Backend Developer</option>
65        <option value="fullstack">Fullstack Developer</option>
66        <option value="designer">UI/UX Designer</option>
67      </select>
68      <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Expérience (années) *</label>
69      <input type="number" value={data.experience} onChange={e => onChange({ experience: e.target.value })} placeholder="3" min="0" max="50" style={inputStyle} />
70    </div>
71  );
72}
73
74function Step3({ data, onChange }: { data: Step3Data; onChange: (d: Partial<Step3Data>) => void }) {
75  const plans = [
76    { value: 'free', label: 'Free', desc: 'Accès basique' },
77    { value: 'pro', label: 'Pro', desc: 'Accès complet + support' },
78    { value: 'enterprise', label: 'Enterprise', desc: 'Pour les équipes + SLA' },
79  ];
80  return (
81    <div>
82      <h3 style={{ color: '#64ffda', margin: '0 0 12px' }}>Étape 3 : Choisis un forfait</h3>
83      {plans.map(p => (
84        <label key={p.value} style={{
85          display: 'flex', alignItems: 'center', gap: 10, padding: 12, borderRadius: 6,
86          background: data.plan === p.value ? '#1a2744' : '#0d1117',
87          border: data.plan === p.value ? '1px solid #64ffda' : '1px solid #333',
88          cursor: 'pointer', marginBottom: 8
89        }}>
90          <input type="radio" name="plan" value={p.value} checked={data.plan === p.value}
91            onChange={() => onChange({ plan: p.value })} />
92          <div>
93            <strong style={{ color: data.plan === p.value ? '#64ffda' : '#e0e0e0' }}>{p.label}</strong>
94            <div style={{ color: '#888', fontSize: 11 }}>{p.desc}</div>
95          </div>
96        </label>
97      ))}
98      <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, cursor: 'pointer' }}>
99        <input type="checkbox" checked={data.newsletter} onChange={e => onChange({ newsletter: e.target.checked })} />
100        Je souhaite recevoir la newsletter
101      </label>
102    </div>
103  );
104}
105
106export default function MultiStepFormDemo() {
107  const [step, setStep] = useState(1);
108  const [data, setData] = useState<AllData>({
109    firstName: '', lastName: '', email: '',
110    company: '', role: '', experience: '',
111    plan: 'free', newsletter: false,
112  });
113  const [errors, setErrors] = useState<string[]>([]);
114  const [complete, setComplete] = useState(false);
115
116  const update = (partial: Partial<AllData>) => { setData(prev => ({ ...prev, ...partial })); setErrors([]); };
117
118  const validateStep = () => {
119    const errs: string[] = [];
120    if (step === 1) {
121      if (!data.firstName) errs.push('Le prénom est requis');
122      if (!data.lastName) errs.push('Le nom est requis');
123      if (!data.email) errs.push('L\'email est requis');
124    } else if (step === 2) {
125      if (!data.role) errs.push('Choisis un poste');
126      if (!data.experience) errs.push('Indique ton expérience');
127    }
128    setErrors(errs);
129    return errs.length === 0;
130  };
131
132  const next = () => { if (validateStep()) setStep(s => Math.min(s + 1, TOTAL_STEPS)); };
133  const prev = () => { setErrors([]); setStep(s => Math.max(s - 1, 1)); };
134  const submit = (e: React.FormEvent) => { e.preventDefault(); if (validateStep()) setComplete(true); };
135
136  if (complete) {
137    return (
138      <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
139        <div style={{ maxWidth: 400, margin: '0 auto', textAlign: 'center' }}>
140          <div style={{ fontSize: 40, marginBottom: 12 }}>✅</div>
141          <h2 style={{ color: '#64ffda' }}>Inscription terminée !</h2>
142          <div style={{ background: '#0d1117', borderRadius: 8, padding: 12, marginTop: 12, textAlign: 'left', fontSize: 12 }}>
143            <p><strong>Prénom :</strong> {data.firstName} {data.lastName}</p>
144            <p><strong>Email :</strong> {data.email}</p>
145            <p><strong>Entreprise :</strong> {data.company || 'Aucune'}</p>
146            <p><strong>Poste :</strong> {data.role}</p>
147            <p><strong>Expérience :</strong> {data.experience} ans</p>
148            <p><strong>Plan :</strong> {data.plan}</p>
149            <p><strong>Newsletter :</strong> {data.newsletter ? 'Oui' : 'Non'}</p>
150          </div>
151          <button onClick={() => { setComplete(false); setStep(1); setData({ firstName: '', lastName: '', email: '', company: '', role: '', experience: '', plan: 'free', newsletter: false }); }}
152            style={{ marginTop: 12, padding: '8px 16px', borderRadius: 6, border: '1px solid #64ffda', background: 'transparent', color: '#64ffda', cursor: 'pointer', fontFamily: 'monospace' }}>
153            Nouvelle inscription
154          </button>
155        </div>
156      </div>
157    );
158  }
159
160  return (
161    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
162      <h1 style={{ color: '#64ffda' }}>Formulaire multi-étapes</h1>
163      <p style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
164        Un processus d'inscription en 3 étapes avec validation à chaque étape.
165      </p>
166
167      <div style={{ maxWidth: 400 }}>
168        <StepIndicator current={step} total={TOTAL_STEPS} />
169
170        {errors.length > 0 && (
171          <div role="alert" style={{ padding: 10, background: '#3e1010', borderRadius: 6, marginBottom: 12, border: '1px solid #f44336' }}>
172            {errors.map((err, i) => <p key={i} style={{ color: '#f44336', fontSize: 12, margin: '2px 0' }}>{err}</p>)}
173          </div>
174        )}
175
176        <form onSubmit={submit}>
177          {step === 1 && <Step1 data={data} onChange={update} />}
178          {step === 2 && <Step2 data={data} onChange={update} />}
179          {step === 3 && <Step3 data={data} onChange={update} />}
180
181          <div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
182            {step > 1 && (
183              <button type="button" onClick={prev} style={{
184                flex: 1, padding: '10px', borderRadius: 6, border: '1px solid #444',
185                background: 'transparent', color: '#aaa', cursor: 'pointer', fontFamily: 'monospace'
186              }}>Retour</button>
187            )}
188            {step < TOTAL_STEPS ? (
189              <button type="button" onClick={next} style={{
190                flex: 1, padding: '10px', borderRadius: 6, border: 'none',
191                background: '#64ffda', color: '#0f0f23', fontWeight: 'bold', cursor: 'pointer', fontFamily: 'monospace'
192              }}>Suivant</button>
193            ) : (
194              <button type="submit" style={{
195                flex: 1, padding: '10px', borderRadius: 6, border: 'none',
196                background: '#4caf50', color: '#fff', fontWeight: 'bold', cursor: 'pointer', fontFamily: 'monospace'
197              }}>S'inscrire</button>
198            )}
199          </div>
200        </form>
201      </div>
202    </div>
203  );
204}

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

Teste tes connaissances

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

  1. 1. L'optimisation des formulaires pour la performance inclut :

  2. 2. Les mises à jour optimistes dans les formulaires signifient :

Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne la syntaxe de useForm avec l'intégration de zodResolver

  • Tri vertical

    Ordonne les éléments dans le bon ordre : Sélectionner le fichier → Validation client → Téléverser

  • Tri horizontal

    Ordonne la syntaxe du composant ErrorMessage pour afficher les erreurs

  • Tri vertical

    Ordonne dans l'ordre les étapes du pipeline de validation de formulaire

  • Tri vertical

    Ordonne dans l'ordre les étapes d'un formulaire de commande multi-étapes

  • Clique dans l'ordre

    Ordonne la syntaxe d'un élément form avec action

  • Clique dans l'ordre

    Ordonne la chaîne de validation Zod pour un mot de passe

  • Tri horizontal

    Ordonne le motif pour accéder au message d'erreur du champ e-mail dans formState

  • Tri vertical

    Ordonne les éléments dans le bon ordre : Validation côté client → Soumission du formulaire

  • Tri horizontal

    Ordonne la syntaxe d'un formulaire avec Server Action dans Next.js

  • Tri horizontal

    Ordonne la syntaxe pour créer un objet FormData à partir d'un élément form

  • Éditeur de code

    Crée un framework de sécurité complet : 1. Système d'authentification - OAuth multi-providers avec implémentation JWT personnalisée 2. Moteur d'autorisation - RBAC/ABAC avec permissions fines 3. Gestion des sessions - gestion sécurisée des sessions avec déconnexion automatique 4. Protection des données - chiffrement de bout en bout avec gestion des clés 5. En-têtes de sécurité - configuration CSP, HSTS, protection XSS 6. Validation des entrées - assainissement avancé avec validateurs personnalisés 7. Limitation de débit - rate limiting distribué avec Redis 8. Système d'audit - journalisation complète avec détection d'anomalies 9. Tests de pénétration - analyse de sécurité automatisée 10. Outils de conformité - automatisation de la conformité RGPD, CCPA Utilise des patterns d'entreprise : NextAuth.js, jose, bcryptjs, helmet, rate-limiter.

Articles utiles