Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Wielokrokowe formularze i przepływy

Tworzenie złożonych, wieloetapowych formularzy to powszechne zadanie w aplikacjach webowych. Takie formularze są często wykorzystywane do procesów rejestracji, rezerwacji, zamówień czy konfiguracji produktów. W tym module przyjrzymy się różnym strategiom implementacji wielokrokowych formularzy w środowisku React i Next.js.

Dlaczego warto rozdzielać formularze na etapy?

Rozdzielenie długiego formularza na mniejsze kroki przynosi wiele korzyści:

  1. Lepsza doświadczenie użytkownika - krótsze formularze są mniej przytłaczające i zmniejszają ryzyko porzucenia
  2. Większa przejrzystość - użytkownicy mogą łatwiej skupić się na jednym aspekcie procesu naraz
  3. Progresywne zbieranie danych - możemy walidować każdy krok osobno i dostosowywać kolejne etapy na podstawie wcześniejszych odpowiedzi
  4. Organizacja logiczna - grupowanie powiązanych pól formularza poprawia strukturę i zrozumienie
  5. Możliwość zapisu częściowego - użytkownicy mogą zapisać swój postęp i wrócić później

Podstawowe podejścia do wieloetapowych formularzy

Istnieje kilka głównych podejść do implementacji wieloetapowych formularzy:

1. Podejście oparte na stanie komponentu

Najprostszym sposobem jest przechowywanie obecnego kroku i wszystkich danych formularza w stanie komponentu:

1'use client';
2
3import { useState, FormEvent } from 'react';
4
5// Typy danych dla poszczególnych kroków
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// Połączone dane formularza
26interface FormData {
27  user: UserDetails;
28  address: AddressDetails;
29  payment: PaymentDetails;
30}
31
32export default function MultiStepForm() {
33  // Stan aktualnego kroku
34  const [step, setStep] = useState(1);
35  
36  // Stan danych formularza
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  // Stan walidacji
57  const [errors, setErrors] = useState<Record<string, string>>({});
58  
59  // Obsługa zmiany pól formularza
60  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
61    const { name, value } = e.target;
62    
63    // Określenie, do którego kroku należy pole
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    // Usuwanie błędu po edycji pola
91    if (errors[name]) {
92      setErrors({
93        ...errors,
94        [name]: ''
95      });
96    }
97  }
98  
99  // Walidacja danych formularza dla każdego kroku
100  function validateStep(stepNumber: number): boolean {
101    const newErrors: Record<string, string> = {};
102    
103    if (stepNumber === 1) {
104      // Walidacja danych użytkownika
105      if (!formData.user.firstName.trim()) {
106        newErrors.firstName = 'Imię jest wymagane';
107      }
108      
109      if (!formData.user.lastName.trim()) {
110        newErrors.lastName = 'Nazwisko jest wymagane';
111      }
112      
113      if (!formData.user.email.trim()) {
114        newErrors.email = 'Email jest wymagany';
115      } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i.test(formData.user.email)) {
116        newErrors.email = 'Niepoprawny format adresu email';
117      }
118    } else if (stepNumber === 2) {
119      // Walidacja adresu
120      if (!formData.address.street.trim()) {
121        newErrors.street = 'Ulica jest wymagana';
122      }
123      
124      if (!formData.address.city.trim()) {
125        newErrors.city = 'Miasto jest wymagane';
126      }
127      
128      if (!formData.address.zipCode.trim()) {
129        newErrors.zipCode = 'Kod pocztowy jest wymagany';
130      }
131      
132      if (!formData.address.country.trim()) {
133        newErrors.country = 'Kraj jest wymagany';
134      }
135    } else if (stepNumber === 3) {
136      // Walidacja płatności
137      if (!formData.payment.cardNumber.trim()) {
138        newErrors.cardNumber = 'Numer karty jest wymagany';
139      } else if (!/^d{16}$/.test(formData.payment.cardNumber.replace(/s/g, ''))) {
140        newErrors.cardNumber = 'Niepoprawny numer karty';
141      }
142      
143      if (!formData.payment.expiryDate.trim()) {
144        newErrors.expiryDate = 'Data ważności jest wymagana';
145      }
146      
147      if (!formData.payment.cvv.trim()) {
148        newErrors.cvv = 'Kod CVV jest wymagany';
149      } else if (!/^d{3,4}$/.test(formData.payment.cvv)) {
150        newErrors.cvv = 'Niepoprawny kod CVV';
151      }
152    }
153    
154    setErrors(newErrors);
155    return Object.keys(newErrors).length === 0;
156  }
157  
158  // Przejście do następnego kroku
159  function handleNext() {
160    const isValid = validateStep(step);
161    
162    if (isValid) {
163      setStep(step + 1);
164    }
165  }
166  
167  // Powrót do poprzedniego kroku
168  function handlePrevious() {
169    setStep(step - 1);
170  }
171  
172  // Przesłanie całego formularza
173  function handleSubmit(e: FormEvent) {
174    e.preventDefault();
175    
176    const isValid = validateStep(step);
177    
178    if (isValid) {
179      // Tutaj kod do wysłania danych do API
180      console.log('Przesyłanie danych formularza:', formData);
181      
182      // Przykładowe wywołanie API (w rzeczywistej aplikacji)
183      // await fetch('/api/checkout', {
184      //   method: 'POST',
185      //   headers: { 'Content-Type': 'application/json' },
186      //   body: JSON.stringify(formData)
187      // });
188      
189      // Opcjonalnie: przekierowanie do strony potwierdzenia
190      // router.push('/checkout/success');
191    }
192  }
193  
194  // Renderowanie odpowiedniego kroku
195  function renderStep() {
196    switch (step) {
197      case 1:
198        return (
199          <div className="space-y-4">
200            <h2 className="text-xl font-semibold">Dane osobowe</h2>
201            
202            <div>
203              <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
204                Imię
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                Nazwisko
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                Dalej
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">Adres dostawy</h2>
275            
276            <div>
277              <label htmlFor="street" className="block text-sm font-medium text-gray-700">
278                Ulica i numer
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                Miasto
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                Kod pocztowy
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                Kraj
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                Wstecz
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                Dalej
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">Dane płatności</h2>
375            
376            <div>
377              <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
378                Numer karty
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                  Data ważności
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                Wstecz
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                Złóż zamówienie
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' : ''}>Dane osobowe</span>
489          <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adres</span>
490          <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Płatność</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}

Główne cechy tego podejścia:

  • Pojedynczy komponent zarządza całym procesem
  • Stan zawiera wszystkie dane formularza i aktualny krok
  • Przełączanie między krokami odbywa się przez zmianę stanu
  • Walidacja jest wykonywana przed przejściem do następnego kroku

2. Podejście z komponentami dla każdego kroku

Alternatywnym podejściem jest stworzenie osobnych komponentów dla każdego kroku, co poprawia organizację kodu i możliwość ponownego użycia:

1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6// Typy dla danych formularza
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// Komponent dla kroku 1: Dane użytkownika
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    // Czyszczenie błędów
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 = 'Imię jest wymagane';
57    }
58    
59    if (!formData.lastName.trim()) {
60      newErrors.lastName = 'Nazwisko jest wymagane';
61    }
62    
63    if (!formData.email.trim()) {
64      newErrors.email = 'Email jest wymagany';
65    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i.test(formData.email)) {
66      newErrors.email = 'Niepoprawny format adresu email';
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">Dane osobowe</h2>
84      
85      <div>
86        <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
87          Imię
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          Nazwisko
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          Dalej
148        </button>
149      </div>
150    </form>
151  );
152}
153
154// Komponent dla kroku 2: Adres dostawy
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 = 'Ulica jest wymagana';
179    }
180    
181    if (!formData.city.trim()) {
182      newErrors.city = 'Miasto jest wymagane';
183    }
184    
185    if (!formData.zipCode.trim()) {
186      newErrors.zipCode = 'Kod pocztowy jest wymagany';
187    }
188    
189    if (!formData.country.trim()) {
190      newErrors.country = 'Kraj jest wymagany';
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">Adres dostawy</h2>
208      
209      <div>
210        <label htmlFor="street" className="block text-sm font-medium text-gray-700">
211          Ulica i numer
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          Miasto
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          Kod pocztowy
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          Kraj
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          Wstecz
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          Dalej
298        </button>
299      </div>
300    </form>
301  );
302}
303
304// Komponent dla kroku 3: Płatność
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 = 'Numer karty jest wymagany';
329    } else if (!/^d{16}$/.test(formData.cardNumber.replace(/s/g, ''))) {
330      newErrors.cardNumber = 'Niepoprawny numer karty';
331    }
332    
333    if (!formData.expiryDate.trim()) {
334      newErrors.expiryDate = 'Data ważności jest wymagana';
335    }
336    
337    if (!formData.cvv.trim()) {
338      newErrors.cvv = 'Kod CVV jest wymagany';
339    } else if (!/^d{3,4}$/.test(formData.cvv)) {
340      newErrors.cvv = 'Niepoprawny kod CVV';
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">Dane płatności</h2>
358      
359      <div>
360        <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
361          Numer karty
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            Data ważności
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          Wstecz
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          Złóż zamówienie
431        </button>
432      </div>
433    </form>
434  );
435}
436
437// Komponent potwierdzenia
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      // Symulacja wysyłki do API
449      console.log('Przesyłanie zamówienia:', data);
450      
451      // Przykładowe wywołanie API (w rzeczywistej aplikacji)
452      // await fetch('/api/checkout', {
453      //   method: 'POST',
454      //   headers: { 'Content-Type': 'application/json' },
455      //   body: JSON.stringify(data)
456      // });
457      
458      // Przekierowanie do strony sukcesu
459      // router.push('/checkout/success');
460      
461      // W przykładzie po prostu alertujemy sukces
462      alert('Zamówienie zostało złożone pomyślnie!');
463    } catch (error) {
464      console.error('Błąd podczas składania zamówienia:', error);
465      alert('Wystąpił błąd podczas składania zamówienia. Spróbuj ponownie.');
466    }
467  }
468  
469  return (
470    <div className="space-y-6">
471      <h2 className="text-xl font-semibold">Potwierdzenie zamówienia</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">Dane osobowe</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            Edytuj
486          </button>
487        </div>
488        
489        <div>
490          <h3 className="font-medium text-gray-700 mb-2">Adres dostawy</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            Edytuj
502          </button>
503        </div>
504        
505        <div>
506          <h3 className="font-medium text-gray-700 mb-2">Sposób płatności</h3>
507          <div className="bg-gray-50 p-3 rounded">
508            <p>Karta: **** **** **** {data.payment.cardNumber.slice(-4)}</p>
509            <p>Ważna do: {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            Edytuj
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          Potwierdź i złóż zamówienie
528        </button>
529      </div>
530    </div>
531  );
532}
533
534// Główny komponent formularza wielokrokowego
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' : ''}>Dane osobowe</span>
590          <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Adres</span>
591          <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Płatność</span>
592          <span className={step >= 4 ? 'text-blue-500 font-medium' : ''}>Potwierdzenie</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}

Zalety tego podejścia:

  • Lepsza organizacja kodu
  • Każdy krok jest oddzielnym, niezależnym komponentem
  • Łatwiejsza walidacja dla każdego kroku
  • Możliwość ponownego użycia komponentów w innych miejscach
  • Łatwiejsze testowanie poszczególnych etapów

3. Wielokrokowe formularze z Server Action w Next.js

Dla aplikacji Next.js możemy wykorzystać Server Actions do obsługi danych formularza:

1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { submitUserData, submitAddressData, submitPaymentData, completeOrder } from './actions';
6
7// Podobne typy i komponenty formularzy jak w poprzednim przykładzie
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: { /* dane użytkownika */ },
14    address: { /* adres */ },
15    payment: { /* płatność */ }
16  });
17  
18  async function handleUserSubmit(userData: UserFormData) {
19    try {
20      // Zapisujemy dane użytkownika z użyciem 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        // Obsługa błędów
29        alert(result.error || 'Wystąpił błąd. Spróbuj ponownie.');
30      }
31    } catch (error) {
32      console.error('Error submitting user data:', error);
33      alert('Wystąpił nieoczekiwany błąd. Spróbuj ponownie.');
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 || 'Wystąpił błąd. Spróbuj ponownie.');
48      }
49    } catch (error) {
50      console.error('Error submitting address data:', error);
51      alert('Wystąpił nieoczekiwany błąd. Spróbuj ponownie.');
52    }
53  }
54  
55  // Podobne funkcje dla pozostałych kroków
56  //
57  // Render komponentów formularza dla każdego kroku, podobnie jak wcześniej
58  //
59}

A oto przykładowa implementacja Server Actions:

1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Schematy walidacji dla formularzy
7const UserSchema = z.object({
8  firstName: z.string().min(1, { message: 'Imię jest wymagane' }),
9  lastName: z.string().min(1, { message: 'Nazwisko jest wymagane' }),
10  email: z.string().email({ message: 'Niepoprawny adres email' })
11});
12
13const AddressSchema = z.object({
14  street: z.string().min(1, { message: 'Ulica jest wymagana' }),
15  city: z.string().min(1, { message: 'Miasto jest wymagane' }),
16  zipCode: z.string().min(1, { message: 'Kod pocztowy jest wymagany' }),
17  country: z.string().min(1, { message: 'Kraj jest wymagany' })
18});
19
20// Typy odpowiedzi z serwera
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    // Walidacja danych
28    const validatedData = UserSchema.safeParse(formData);
29    
30    if (!validatedData.success) {
31      return {
32        success: false,
33        error: validatedData.error.errors[0]?.message || 'Niepoprawne dane'
34      };
35    }
36    
37    // W prawdziwej aplikacji: zapisanie danych w bazie
38    // const user = await db.user.create({ data: validatedData.data });
39    
40    // Symulacja utworzenia użytkownika
41    console.log('Zapisano dane użytkownika:', validatedData.data);
42    
43    return {
44      success: true,
45      data: { userId: 'user_123' }
46    };
47  } catch (error) {
48    console.error('Server error when submitting user data:', error);
49    return {
50      success: false,
51      error: 'Wystąpił błąd podczas przetwarzania danych'
52    };
53  }
54}
55
56export async function submitAddressData(
57  userId: string, 
58  formData: unknown
59): Promise<ServerResponse<{ addressId: string }>> {
60  try {
61    // Walidacja danych
62    const validatedData = AddressSchema.safeParse(formData);
63    
64    if (!validatedData.success) {
65      return {
66        success: false,
67        error: validatedData.error.errors[0]?.message || 'Niepoprawne dane adresu'
68      };
69    }
70    
71    // W prawdziwej aplikacji: zapisanie adresu w bazie
72    // const address = await db.address.create({ 
73    //   data: { ...validatedData.data, userId } 
74    // });
75    
76    // Symulacja zapisania adresu
77    console.log('Zapisano adres dla użytkownika', userId, ':', validatedData.data);
78    
79    return {
80      success: true,
81      data: { addressId: 'addr_456' }
82    };
83  } catch (error) {
84    console.error('Server error when submitting address data:', error);
85    return {
86      success: false,
87      error: 'Wystąpił błąd podczas przetwarzania adresu'
88    };
89  }
90}
91
92// Podobne funkcje dla płatności i finalizacji zamówienia
93//

Zalety tego podejścia:

  • Walidacja i przetwarzanie danych na serwerze
  • Możliwość stopniowego zapisywania danych w bazie
  • Lepsza ochrona danych wrażliwych (np. dane płatności)
  • Wsparcie dla progresywnego ulepszania

Zarządzanie stanem w wieloetapowych formularzach

W przypadku bardziej złożonych formularzy warto rozważyć użycie bibliotek do zarządzania stanem formularza:

Zarządzanie stanem z Formik lub React Hook Form

1// Przykład z React Hook Form
2import { useForm, FormProvider, useFormContext } from 'react-hook-form';
3import { zodResolver } from '@hookform/resolvers/zod';
4import { z } from 'zod';
5
6// Schemat walidacji
7const CheckoutSchema = z.object({
8  user: z.object({
9    firstName: z.string().min(1, 'Imię jest wymagane'),
10    lastName: z.string().min(1, 'Nazwisko jest wymagane'),
11    email: z.string().email('Niepoprawny adres email')
12  }),
13  address: z.object({
14    street: z.string().min(1, 'Ulica jest wymagana'),
15    city: z.string().min(1, 'Miasto jest wymagane'),
16    zipCode: z.string().min(1, 'Kod pocztowy jest wymagany'),
17    country: z.string().min(1, 'Kraj jest wymagany')
18  }),
19  payment: z.object({
20    cardNumber: z.string().refine(val => /^d{16}$/.test(val.replace(/s/g, '')), {
21      message: 'Niepoprawny numer karty'
22    }),
23    expiryDate: z.string().min(1, 'Data ważności jest wymagana'),
24    cvv: z.string().refine(val => /^d{3,4}$/.test(val), {
25      message: 'Niepoprawny kod CVV'
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">Dane osobowe</h2>
38      
39      <div>
40        <label htmlFor="firstName" className="block text-sm font-medium">Imię</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      {/* Pozostałe pola... */}
54    </div>
55  );
56}
57
58// Podobnie definiujemy pozostałe kroki formularza
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('Przesyłanie danych:', data);
75    // Logika przesyłania danych do 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        {/* Pasek postępu */}
92        
93        {/* Renderowanie odpowiedniego kroku */}
94        {step === 1 && <UserFormStep />}
95        {step === 2 && <AddressFormStep />}
96        {step === 3 && <PaymentFormStep />}
97        
98        {/* Przyciski nawigacyjne */}
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              Wstecz
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 ? 'Złóż zamówienie' : 'Dalej'}
118          </button>
119        </div>
120      </form>
121    </FormProvider>
122  );
123}

Zarządzanie stanem z użyciem kontekstu React

Dla bardzo złożonych formularzy może być korzystne użycie React Context API:

1// context/CheckoutContext.tsx
2import { createContext, useContext, useReducer, ReactNode } from 'react';
3
4// Typy danych formularza
5//
6// Stan formularza
7interface CheckoutState {
8  step: number;
9  user: UserFormData;
10  address: AddressFormData;
11  payment: PaymentFormData;
12  errors: Record<string, string>;
13}
14
15// Typy akcji
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// Początkowy stan
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// Typ kontekstu
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// Kontekst
66const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined);
67
68// Provider
69export function CheckoutProvider({ children }: { children: ReactNode }) {
70  const [state, dispatch] = useReducer(checkoutReducer, initialState);
71  
72  // Funkcje pomocnicze
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  // Walidacja kroków
96  const validateStep = (step: number): boolean => {
97    // Logika walidacji...
98    return true; // Uproszczenie dla przykładu
99  };
100  
101  // Przesyłanie zamówienia
102  const submitOrder = async () => {
103    if (validateStep(state.step)) {
104      try {
105        // Logika przesyłania danych
106        console.log('Przesyłanie danych:', state);
107      } catch (error) {
108        console.error('Error submitting order:', 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 do użycia kontekstu
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}

Zaawansowane techniki dla formularzy wieloetapowych

1. Warunkowe ścieżki formularza

Czasami chcemy, aby użytkownicy przechodzili różnymi ścieżkami formularza w zależności od ich wyborów:

1// Przykładowa implementacja warunkowych ścieżek
2function determineNextStep(currentStep: number, formData: FormData): number {
3  if (currentStep === 1) {
4    // Jeśli użytkownik wybrał wysyłkę do innego adresu, dodaj krok adresu dostawy
5    if (formData.shippingDifferent) {
6      return 2; // Krok adresu dostawy
7    } else {
8      return 3; // Pomiń krok adresu dostawy, przejdź do płatności
9    }
10  }
11  
12  if (currentStep === 3) {
13    // Jeśli użytkownik wybrał płatność przelewem, pomiń krok danych karty
14    if (formData.paymentMethod === 'bankTransfer') {
15      return 5; // Przejdź do podsumowania
16    } else {
17      return 4; // Krok danych karty
18    }
19  }
20  
21  // Domyślne przejście do następnego kroku
22  return currentStep + 1;
23}
24
25// Użycie w komponencie
26function handleNext() {
27  const isValid = validateCurrentStep();
28  
29  if (isValid) {
30    const nextStep = determineNextStep(step, formData);
31    setStep(nextStep);
32  }
33}

2. Zapisywanie i odtwarzanie stanu formularza

Dla długich formularzy warto umożliwić zapisanie postępu:

1'use client';
2
3import { useState, useEffect } from 'react';
4
5// Zapisywanie stanu formularza
6function saveFormState(data: FormData) {
7  localStorage.setItem('checkout_form', JSON.stringify(data));
8  localStorage.setItem('checkout_timestamp', Date.now().toString());
9}
10
11// Odtwarzanie stanu formularza
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  // Sprawdź, czy zapisane dane nie są za stare (np. 24 godziny)
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    // Dane są za stare, usuń je
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    // Domyślny stan formularza
42  });
43  
44  const [step, setStep] = useState(1);
45  const [hasRestoredState, setHasRestoredState] = useState(false);
46  
47  // Próba odtworzenia stanu formularza przy pierwszym renderowaniu
48  useEffect(() => {
49    if (!hasRestoredState) {
50      const savedState = getSavedFormState();
51      
52      if (savedState) {
53        setFormData(savedState);
54        setHasRestoredState(true);
55        
56        // Opcjonalnie, zapytaj użytkownika, czy chce kontynuować od zapisanego miejsca
57        const wishToContinue = window.confirm(
58          'Znaleziono niekompletny formularz z poprzedniej sesji. Czy chcesz kontynuować od zapisanego miejsca?'
59        );
60        
61        if (wishToContinue) {
62          // Możemy ustalić, na którym kroku użytkownik zakończył
63          // np. na podstawie tego, które dane są już wypełnione
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          // Użytkownik nie chce kontynuować, wyczyść zapisane dane
73          localStorage.removeItem('checkout_form');
74          localStorage.removeItem('checkout_timestamp');
75          setFormData({
76            // Zresetuj do domyślnego stanu
77          });
78        }
79      }
80      
81      setHasRestoredState(true);
82    }
83  }, [hasRestoredState]);
84  
85  // Zapisuj stan formularza przy zmianach
86  useEffect(() => {
87    if (hasRestoredState) {
88      saveFormState(formData);
89    }
90  }, [formData, hasRestoredState]);
91  
92  // Reszta komponentu...
93}

3. Dynamiczne dodawanie i usuwanie pól formularza

W niektórych formularzach wieloetapowych użytkownicy mogą potrzebować dynamicznie dodawać lub usuwać elementy:

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    // Nie usuwaj ostatniego elementu
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">Elementy zamówienia</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="flex-grow">
41            <label htmlFor={`item-name-${item.id}`} className="block text-sm font-medium">
42              Nazwa produktu
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              Ilość
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="Usuń element"
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        + Dodaj więcej produktów
84      </button>
85    </div>
86  );
87}

Dobre praktyki dla formularzy wieloetapowych

  1. Pokaż postęp - wskazuj użytkownikowi, w którym miejscu procesu się znajduje (pasek postępu, numeracja kroków)

  2. Zachowaj dane - użytkownicy nie powinni tracić wprowadzonych danych w przypadku odświeżenia strony lub przypadkowego zamknięcia

  3. Umożliw nawigację między krokami - pozwól użytkownikom wrócić do poprzednich kroków i edytować dane

  4. Waliduj każdy krok - sprawdzaj poprawność danych przed przejściem do następnego kroku

  5. Dostarczaj jasne komunikaty o błędach - informuj użytkownika dokładnie, co poszło nie tak

  6. Zapewnij możliwość zapisania i powrotu później - zwłaszcza dla długich formularzy

  7. Dostosuj kroki do urządzenia - na mniejszych ekranach rozważ jeszcze mniejsze kroki

  8. Zapewnij podsumowanie przed finalnym przesłaniem - daj użytkownikom przegląd wszystkich wprowadzonych danych

  9. Obsługuj wstecz/dalej w przeglądarce - dostosuj aplikację do działania przycisku wstecz/dalej przeglądarki

  10. Testuj różne scenariusze - upewnij się, że formularz działa poprawnie zarówno w przypadku standardowego wypełniania, jak i w scenariuszach takich jak:

    • Cofanie się do poprzednich kroków
    • Odświeżanie strony
    • Próba przesłania niepełnych danych
    • Utrata połączenia internetowego

Drizzle ORM - lekka alternatywa dla Prisma

W Metropolii Quantum roku 2150, gdzie każdy cykl procesora ma znaczenie, developerzy często szukają narzędzi, które są zarówno potężne, jak i lekkie. Drizzle ORM to nowoczesna alternatywa dla Prisma, która oferuje type safety przy jednoczesnym zachowaniu bliskości do czystego SQL.

Drizzle vs Prisma - porównanie

Zarówno Drizzle jak i Prisma to type-safe ORM-y dla TypeScript, ale różnią się w filozofii i podejściu:

| Aspekt | Drizzle ORM | Prisma | |--------|-------------|--------| | Filozofia | SQL-like, bliżej metalu | Abstraction-first, przyjazne API | | Rozmiar | ~20KB | ~600KB+ | | Migracje | SQL lub TypeScript | Własny język DSL | | Queries | Przypominają SQL | Własne API | | Wydajność | Bardzo szybki | Dobry | | Krzywa uczenia | Łagodna dla znających SQL | Łagodna dla początkujących | | Type safety | Pełny | Pełny | | Edge Runtime | Natywne wsparcie | Wymaga Data Proxy |

Dlaczego Drizzle jest lżejszy i bliższy SQL

Drizzle to "thin TypeScript layer" nad SQL, co oznacza, że:

  1. Minimalna abstrakcja - queries wyglądają jak SQL, więc łatwo je zoptymalizować
  2. Mały bundle size - kod Drizzle jest kompaktowy
  3. Zero runtime overhead - wszystkie typy są usuwane podczas kompilacji
  4. Pełna kontrola - możesz używać raw SQL gdy potrzebujesz
1// Drizzle - przypomina SQL
2const result = await db
3  .select()
4  .from(users)
5  .where(eq(users.email, 'user@example.com'))
6  .limit(1);
7
8// Prisma - bardziej abstrakcyjne
9const result = await prisma.user.findUnique({
10  where: { email: 'user@example.com' }
11});

Type-safe queries z Drizzle

Drizzle oferuje pełne wsparcie TypeScript, wykrywając błędy już na etapie pisania kodu:

Definicja schematu

1// db/schema.ts
2import { pgTable, serial, text, varchar, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
3import { relations } from 'drizzle-orm';
4
5// Tabela użytkowników
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// Tabela postów
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// Tabela komentarzy
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// Relacje między tabelami
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// Typy TypeScript automatycznie wygenerowane
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;

Konfiguracja połączenia

1// db/index.ts
2import { drizzle } from 'drizzle-orm/postgres-js';
3import postgres from 'postgres';
4import * as schema from './schema';
5
6// Klient PostgreSQL
7const connectionString = process.env.DATABASE_URL!;
8const client = postgres(connectionString);
9
10// Drizzle instance
11export const db = drizzle(client, { schema });
12
13// Dla 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 });

Type-safe queries

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 - pobieranie danych
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 z wieloma warunkami
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 - tworzenie użytkownika
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 - aktualizacja użytkownika
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 - usuwanie użytkownika
69export async function deleteUser(id: number) {
70  await db
71    .delete(users)
72    .where(eq(users.id, id));
73}
74
75// JOIN - pobieranie użytkownika z postami
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// Agregacje
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// Transakcje
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    // Utwórz użytkownika
105    const [user] = await tx
106      .insert(users)
107      .values(userData)
108      .returning();
109
110    // Utwórz post dla tego użytkownika
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}

Zaawansowane queries

Relacyjne queries (Relational Queries API)

1// Najprostszy sposób na JOIN-y
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// Częściowy select z relacjami
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// Filtrowanie w relacjach
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}

Raw SQL gdy potrzebujesz

1import { sql } from 'drizzle-orm';
2
3// Raw SQL dla zaawansowanych queries
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// Łączenie type-safe queries z raw SQL
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}

Migracje w Drizzle

Drizzle oferuje dwa sposoby na migracje:

1. Automatyczne generowanie SQL

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# Generuj migracje
2npx drizzle-kit generate:pg
3
4# Wykonaj migracje
5npx drizzle-kit push:pg
6
7# Lub używając własnego skryptu
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. Ręczne migracje SQL

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);

Integracja z Next.js App Router

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  // Query wykonywane w 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>Użytkownik nie znaleziony</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">Posty</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 - kiedy wybrać?

Wybierz Drizzle gdy:

  • ✅ Dobrze znasz SQL i chcesz mieć pełną kontrolę
  • ✅ Zależy Ci na małym bundle size
  • ✅ Budujesz edge applications
  • ✅ Potrzebujesz maksymalnej wydajności
  • ✅ Preferujesz minimalizm i prostotę

Wybierz Prisma gdy:

  • ✅ Jesteś początkującym lub średnio-zaawansowanym
  • ✅ Potrzebujesz doskonałej dokumentacji i ekosystemu
  • ✅ Chcesz abstrakcję i nie martwić się o SQL
  • ✅ Używasz Prisma Studio do zarządzania danymi
  • ✅ Team jest większy i potrzebuje spójnych konwencji

Praktyczne zastosowanie

W Metropolii Quantum, gdzie aplikacje muszą być zarówno potężne jak i lekkie, Drizzle oferuje doskonałą alternatywę. Jego bliskość do SQL oznacza, że doświadczeni developerzy mogą łatwo optymalizować queries, podczas gdy pełne wsparcie TypeScript zapewnia bezpieczeństwo typów na każdym kroku.

1// Przykład real-world: Dashboard z analytics
2export async function getDashboardStats(userId: number) {
3  // Wszystko type-safe, przypomina SQL, szybkie
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}

Podsumowanie

Wielokrokowe formularze to potężne narzędzie do zbierania złożonych danych od użytkowników w sposób przyjazny i zorganizowany. Istnieje wiele strategii ich implementacji, od prostych opartych o stan komponentu, przez modułowe podejście z oddzielnymi komponentami dla każdego kroku, po zaawansowane rozwiązania wykorzystujące kontekst React i biblioteki do zarządzania formularzami.

W React i Next.js mamy elastyczne możliwości implementacji takich formularzy, z dodatkowymi zaletami jakie oferują Server Actions w Next.js dla progresywnego ulepszania i bezpieczeństwa danych.

Kluczem do sukcesu jest wybór podejścia odpowiedniego do złożoności formularza i potrzeb aplikacji, jednocześnie zapewniając użytkownikom płynne i bezproblemowe doświadczenie.

Przejdź do CodeWorlds