Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

React Hook Form i zarządzanie stanem formularzy

Formularze są integralną częścią większości aplikacji webowych, ale skuteczne zarządzanie ich stanem, walidacją i obsługą błędów może być wyzwaniem. React Hook Form to jedna z najpopularniejszych bibliotek w ekosystemie React, która została zaprojektowana, aby rozwiązać typowe problemy związane z formularzami, jednocześnie zachowując wysoką wydajność i niską złożoność kodu.

Dlaczego warto użyć React Hook Form?

Przed zgłębieniem szczegółów implementacji, przyjrzyjmy się głównym zaletom React Hook Form w porównaniu do natywnych rozwiązań React:

  1. Minimalne renderowanie - biblioteka minimalizuje niepotrzebne przerenderowania, co przekłada się na lepszą wydajność
  2. Mniej kodu boilerplate - w porównaniu do natywnych kontrolowanych komponentów
  3. Łatwa integracja - działa zarówno z kontrolowanymi, jak i niekontrolowanymi komponentami
  4. Zaawansowana walidacja - rozbudowane możliwości walidacji, integracja z Yup, Zod, Joi i innymi bibliotekami
  5. Typowanie TypeScript - pełne wsparcie dla TypeScript
  6. Wydajność - zoptymalizowana pod kątem wydajności, nawet w złożonych formularzach

Podstawy React Hook Form

Zacznijmy od prostego przykładu formularza logowania:

1import { useForm, SubmitHandler } from 'react-hook-form';
2
3type LoginInputs = {
4  email: string;
5  password: string;
6  rememberMe: boolean;
7};
8
9export default function LoginForm() {
10  const { 
11    register, 
12    handleSubmit, 
13    formState: { errors, isSubmitting } 
14  } = useForm<LoginInputs>();
15  
16  const onSubmit: SubmitHandler<LoginInputs> = async (data) => {
17    // W rzeczywistej aplikacji wywołalibyśmy tutaj API
18    console.log('Dane logowania:', data);
19    
20    // Symulacja opóźnienia sieciowego
21    await new Promise(resolve => setTimeout(resolve, 1000));
22  };
23  
24  return (
25    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
26      <div>
27        <label htmlFor="email" className="block text-sm font-medium">
28          Email
29        </label>
30        <input
31          id="email"
32          type="email"
33          className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
34          {...register('email', { 
35            required: 'Email jest wymagany', 
36            pattern: {
37              value: /^S+@S+.S+$/,
38              message: 'Nieprawidłowy format email'
39            }
40          })}
41        />
42        {errors.email && (
43          <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
44        )}
45      </div>
46      
47      <div>
48        <label htmlFor="password" className="block text-sm font-medium">
49          Hasło
50        </label>
51        <input
52          id="password"
53          type="password"
54          className={`mt-1 block w-full rounded-md ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
55          {...register('password', { 
56            required: 'Hasło jest wymagane',
57            minLength: {
58              value: 6,
59              message: 'Hasło musi mieć co najmniej 6 znaków'
60            }
61          })}
62        />
63        {errors.password && (
64          <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
65        )}
66      </div>
67      
68      <div className="flex items-center">
69        <input
70          id="rememberMe"
71          type="checkbox"
72          className="h-4 w-4 rounded border-gray-300"
73          {...register('rememberMe')}
74        />
75        <label htmlFor="rememberMe" className="ml-2 block text-sm">
76          Zapamiętaj mnie
77        </label>
78      </div>
79      
80      <button
81        type="submit"
82        disabled={isSubmitting}
83        className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
84      >
85        {isSubmitting ? 'Logowanie...' : 'Zaloguj się'}
86      </button>
87    </form>
88  );
89}

Główne elementy React Hook Form w powyższym przykładzie:

  1. useForm - główny hook, który dostarcza metody do zarządzania formularzem
  2. register - funkcja rejestrująca pola formularza w React Hook Form
  3. handleSubmit - funkcja opakowująca nasz handler wysyłania formularza
  4. formState - obiekt zawierający informacje o stanie formularza, w tym błędy i status wysyłania

Zaawansowana walidacja z Zod

React Hook Form doskonale integruje się z bibliotekami walidacyjnymi, takimi jak Zod, co pozwala na tworzenie solidnych schematów walidacji:

1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// Definicja schematu z Zod
6const signupSchema = z.object({
7  username: z.string()
8    .min(3, 'Nazwa użytkownika musi mieć co najmniej 3 znaki')
9    .max(20, 'Nazwa użytkownika może mieć maksymalnie 20 znaków')
10    .regex(/^[a-zA-Z0-9_]+$/, 'Nazwa użytkownika może zawierać tylko litery, cyfry i podkreślenia'),
11  email: z.string()
12    .email('Podaj prawidłowy adres email'),
13  password: z.string()
14    .min(8, 'Hasło musi mieć co najmniej 8 znaków')
15    .regex(/[A-Z]/, 'Hasło musi zawierać co najmniej jedną wielką literę')
16    .regex(/[a-z]/, 'Hasło musi zawierać co najmniej jedną małą literę')
17    .regex(/[0-9]/, 'Hasło musi zawierać co najmniej jedną cyfrę'),
18  confirmPassword: z.string(),
19  terms: z.boolean().refine(val => val === true, {
20    message: 'Musisz zaakceptować warunki',
21  }),
22}).refine(data => data.password === data.confirmPassword, {
23  message: 'Hasła nie są identyczne',
24  path: ['confirmPassword'],
25});
26
27// Typ wynikający ze schematu
28type SignupInputs = z.infer<typeof signupSchema>;
29
30export default function SignupForm() {
31  const { 
32    register, 
33    handleSubmit, 
34    formState: { errors, isSubmitting, isSubmitSuccessful },
35    reset
36  } = useForm<SignupInputs>({
37    resolver: zodResolver(signupSchema),
38    defaultValues: {
39      username: '',
40      email: '',
41      password: '',
42      confirmPassword: '',
43      terms: false
44    }
45  });
46  
47  const onSubmit: SubmitHandler<SignupInputs> = async (data) => {
48    try {
49      // Symulacja wysyłania danych do API
50      await new Promise(resolve => setTimeout(resolve, 1500));
51      console.log('Dane rejestracji:', data);
52      
53      // Resetowanie formularza po udanym wysłaniu
54      reset();
55    } catch (error) {
56      console.error('Błąd rejestracji:', error);
57    }
58  };
59  
60  return (
61    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
62      <h2 className="text-2xl font-bold mb-6">Rejestracja</h2>
63      
64      {isSubmitSuccessful && (
65        <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
66          Rejestracja zakończona pomyślnie!
67        </div>
68      )}
69      
70      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
71        <div>
72          <label htmlFor="username" className="block text-sm font-medium">
73            Nazwa użytkownika
74          </label>
75          <input
76            id="username"
77            type="text"
78            className={`mt-1 block w-full rounded-md ${errors.username ? 'border-red-500' : 'border-gray-300'}`}
79            {...register('username')}
80          />
81          {errors.username && (
82            <p className="text-red-500 text-xs mt-1">{errors.username.message}</p>
83          )}
84        </div>
85        
86        <div>
87          <label htmlFor="email" className="block text-sm font-medium">
88            Email
89          </label>
90          <input
91            id="email"
92            type="email"
93            className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
94            {...register('email')}
95          />
96          {errors.email && (
97            <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
98          )}
99        </div>
100        
101        <div>
102          <label htmlFor="password" className="block text-sm font-medium">
103            Hasło
104          </label>
105          <input
106            id="password"
107            type="password"
108            className={`mt-1 block w-full rounded-md ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
109            {...register('password')}
110          />
111          {errors.password && (
112            <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
113          )}
114        </div>
115        
116        <div>
117          <label htmlFor="confirmPassword" className="block text-sm font-medium">
118            Potwierdź hasło
119          </label>
120          <input
121            id="confirmPassword"
122            type="password"
123            className={`mt-1 block w-full rounded-md ${errors.confirmPassword ? 'border-red-500' : 'border-gray-300'}`}
124            {...register('confirmPassword')}
125          />
126          {errors.confirmPassword && (
127            <p className="text-red-500 text-xs mt-1">{errors.confirmPassword.message}</p>
128          )}
129        </div>
130        
131        <div className="flex items-center">
132          <input
133            id="terms"
134            type="checkbox"
135            className={`h-4 w-4 rounded ${errors.terms ? 'border-red-500' : 'border-gray-300'}`}
136            {...register('terms')}
137          />
138          <label htmlFor="terms" className="ml-2 block text-sm">
139            Akceptuję warunki korzystania z serwisu
140          </label>
141        </div>
142        {errors.terms && (
143          <p className="text-red-500 text-xs">{errors.terms.message}</p>
144        )}
145        
146        <button
147          type="submit"
148          disabled={isSubmitting}
149          className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
150        >
151          {isSubmitting ? 'Rejestracja...' : 'Zarejestruj się'}
152        </button>
153      </form>
154    </div>
155  );
156}

Kluczowe elementy tego przykładu:

  1. zodResolver - adapter integrujący Zod z React Hook Form
  2. Zaawansowane reguły walidacji - złożone wyrażenia regularne, minimalna długość, itp.
  3. Funkcja refine - do niestandardowych warunków walidacji, takich jak porównywanie haseł
  4. z.infer<typeof schema> - automatyczne generowanie typu z definicji schematu

Dynamiczne formularze i pola warunkowe

React Hook Form doskonale radzi sobie z dynamicznymi formularzami, w których pola mogą pojawiać się lub znikać w zależności od warunków:

1import { useForm, useWatch, SubmitHandler, Controller } from 'react-hook-form';
2import { useState } from 'react';
3
4type DeliveryMethod = 'standard' | 'express' | 'pickup';
5
6type CheckoutInputs = {
7  firstName: string;
8  lastName: string;
9  email: string;
10  deliveryMethod: DeliveryMethod;
11  address?: string;
12  city?: string;
13  postalCode?: string;
14  pickupLocation?: string;
15};
16
17export default function DynamicCheckoutForm() {
18  const { 
19    register, 
20    handleSubmit, 
21    control,
22    formState: { errors, isSubmitting } 
23  } = useForm<CheckoutInputs>({
24    defaultValues: {
25      firstName: '',
26      lastName: '',
27      email: '',
28      deliveryMethod: 'standard',
29    }
30  });
31  
32  // Używamy useWatch, aby obserwować zmiany w wybranej metodzie dostawy
33  const deliveryMethod = useWatch({
34    control,
35    name: 'deliveryMethod',
36    defaultValue: 'standard',
37  });
38  
39  // Sprawdzamy, czy potrzebujemy adresu dostawy
40  const needsShippingAddress = deliveryMethod === 'standard' || deliveryMethod === 'express';
41  
42  const onSubmit: SubmitHandler<CheckoutInputs> = async (data) => {
43    // Symulacja opóźnienia API
44    await new Promise(resolve => setTimeout(resolve, 1000));
45    console.log('Dane zamówienia:', data);
46  };
47  
48  return (
49    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
50      <h2 className="text-2xl font-bold mb-6">Finalizacja zamówienia</h2>
51      
52      <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
53        <div className="grid grid-cols-2 gap-4">
54          <div>
55            <label htmlFor="firstName" className="block text-sm font-medium">
56              Imię
57            </label>
58            <input
59              id="firstName"
60              type="text"
61              className={`mt-1 block w-full rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
62              {...register('firstName', { required: 'Imię jest wymagane' })}
63            />
64            {errors.firstName && (
65              <p className="text-red-500 text-xs mt-1">{errors.firstName.message}</p>
66            )}
67          </div>
68          
69          <div>
70            <label htmlFor="lastName" className="block text-sm font-medium">
71              Nazwisko
72            </label>
73            <input
74              id="lastName"
75              type="text"
76              className={`mt-1 block w-full rounded-md ${errors.lastName ? 'border-red-500' : 'border-gray-300'}`}
77              {...register('lastName', { required: 'Nazwisko jest wymagane' })}
78            />
79            {errors.lastName && (
80              <p className="text-red-500 text-xs mt-1">{errors.lastName.message}</p>
81            )}
82          </div>
83        </div>
84        
85        <div>
86          <label htmlFor="email" className="block text-sm font-medium">
87            Email
88          </label>
89          <input
90            id="email"
91            type="email"
92            className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
93            {...register('email', { 
94              required: 'Email jest wymagany',
95              pattern: {
96                value: /^S+@S+.S+$/,
97                message: 'Nieprawidłowy format email'
98              }
99            })}
100          />
101          {errors.email && (
102            <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
103          )}
104        </div>
105        
106        <div>
107          <label className="block text-sm font-medium mb-2">
108            Metoda dostawy
109          </label>
110          
111          <div className="space-y-2">
112            <label className="flex items-center">
113              <input
114                type="radio"
115                value="standard"
116                className="mr-2"
117                {...register('deliveryMethod')}
118              />
119              Dostawa standardowa (2-3 dni robocze)
120            </label>
121            
122            <label className="flex items-center">
123              <input
124                type="radio"
125                value="express"
126                className="mr-2"
127                {...register('deliveryMethod')}
128              />
129              Dostawa ekspresowa (24h)
130            </label>
131            
132            <label className="flex items-center">
133              <input
134                type="radio"
135                value="pickup"
136                className="mr-2"
137                {...register('deliveryMethod')}
138              />
139              Odbiór osobisty
140            </label>
141          </div>
142        </div>
143        
144        {needsShippingAddress && (
145          <div className="space-y-4 p-4 bg-gray-50 rounded">
146            <h3 className="font-medium">Adres dostawy</h3>
147            
148            <div>
149              <label htmlFor="address" className="block text-sm font-medium">
150                Adres
151              </label>
152              <input
153                id="address"
154                type="text"
155                className={`mt-1 block w-full rounded-md ${errors.address ? 'border-red-500' : 'border-gray-300'}`}
156                {...register('address', { required: 'Adres jest wymagany' })}
157              />
158              {errors.address && (
159                <p className="text-red-500 text-xs mt-1">{errors.address.message}</p>
160              )}
161            </div>
162            
163            <div className="grid grid-cols-2 gap-4">
164              <div>
165                <label htmlFor="city" className="block text-sm font-medium">
166                  Miasto
167                </label>
168                <input
169                  id="city"
170                  type="text"
171                  className={`mt-1 block w-full rounded-md ${errors.city ? 'border-red-500' : 'border-gray-300'}`}
172                  {...register('city', { required: 'Miasto jest wymagane' })}
173                />
174                {errors.city && (
175                  <p className="text-red-500 text-xs mt-1">{errors.city.message}</p>
176                )}
177              </div>
178              
179              <div>
180                <label htmlFor="postalCode" className="block text-sm font-medium">
181                  Kod pocztowy
182                </label>
183                <input
184                  id="postalCode"
185                  type="text"
186                  className={`mt-1 block w-full rounded-md ${errors.postalCode ? 'border-red-500' : 'border-gray-300'}`}
187                  {...register('postalCode', { 
188                    required: 'Kod pocztowy jest wymagany',
189                    pattern: {
190                      value: /^d{2}-d{3}$/,
191                      message: 'Format: 00-000'
192                    }
193                  })}
194                />
195                {errors.postalCode && (
196                  <p className="text-red-500 text-xs mt-1">{errors.postalCode.message}</p>
197                )}
198              </div>
199            </div>
200          </div>
201        )}
202        
203        {deliveryMethod === 'pickup' && (
204          <div>
205            <label htmlFor="pickupLocation" className="block text-sm font-medium">
206              Punkt odbioru
207            </label>
208            <select
209              id="pickupLocation"
210              className={`mt-1 block w-full rounded-md ${errors.pickupLocation ? 'border-red-500' : 'border-gray-300'}`}
211              {...register('pickupLocation', { required: 'Wybierz punkt odbioru' })}
212            >
213              <option value="">Wybierz punkt odbioru</option>
214              <option value="warszawa-centrum">Warszawa - Centrum</option>
215              <option value="warszawa-mokotow">Warszawa - Mokotów</option>
216              <option value="krakow-centrum">Kraków - Centrum</option>
217              <option value="poznan-centrum">Poznań - Centrum</option>
218            </select>
219            {errors.pickupLocation && (
220              <p className="text-red-500 text-xs mt-1">{errors.pickupLocation.message}</p>
221            )}
222          </div>
223        )}
224        
225        <button
226          type="submit"
227          disabled={isSubmitting}
228          className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
229        >
230          {isSubmitting ? 'Przetwarzanie...' : 'Złóż zamówienie'}
231        </button>
232      </form>
233    </div>
234  );
235}

Kluczowe elementy dynamicznego formularza:

  1. useWatch - pozwala na obserwowanie zmian wybranych pól formularza bez wywoływania przerenderowań całego formularza
  2. Warunkowo renderowane sekcje - w zależności od wybranej metody dostawy
  3. Warunkowa walidacja - różne reguły walidacji dla różnych sekcji formularza

Formularze wielostopniowe

React Hook Form doskonale sprawdza się również w wielostopniowych formularzach:

1import { useForm, SubmitHandler, FormProvider, useFormContext } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4import { useState } from 'react';
5
6// Schemat danych osobowych
7const personalInfoSchema = z.object({
8  firstName: z.string().min(1, 'Imię jest wymagane'),
9  lastName: z.string().min(1, 'Nazwisko jest wymagane'),
10  email: z.string().email('Nieprawidłowy format email'),
11  phoneNumber: z.string().regex(/^[0-9]{9}$/, 'Numer telefonu musi mieć 9 cyfr')
12});
13
14// Schemat adresu
15const addressSchema = z.object({
16  street: z.string().min(1, 'Ulica jest wymagana'),
17  city: z.string().min(1, 'Miasto jest wymagane'),
18  postalCode: z.string().regex(/^d{2}-d{3}$/, 'Kod pocztowy musi mieć format 00-000'),
19  country: z.string().min(1, 'Kraj jest wymagany')
20});
21
22// Schemat płatności
23const paymentSchema = z.object({
24  cardName: z.string().min(1, 'Imię i nazwisko na karcie jest wymagane'),
25  cardNumber: z.string().regex(/^[0-9]{16}$/, 'Numer karty musi mieć 16 cyfr'),
26  expiryDate: z.string().regex(/^(0[1-9]|1[0-2])/[0-9]{2}$/, 'Format: MM/RR'),
27  cvv: z.string().regex(/^[0-9]{3,4}$/, 'CVV musi mieć 3 lub 4 cyfry')
28});
29
30// Łączny schemat
31const formSchema = z.object({
32  personalInfo: personalInfoSchema,
33  address: addressSchema,
34  payment: paymentSchema
35});
36
37type MultiStepFormData = z.infer<typeof formSchema>;
38
39// Komponent kroku formularza z danymi osobowymi
40function PersonalInfoStep() {
41  const { register, formState: { errors } } = useFormContext<MultiStepFormData>();
42  
43  return (
44    <div className="space-y-4">
45      <h2 className="text-xl font-bold">Dane osobowe</h2>
46      
47      <div className="grid grid-cols-2 gap-4">
48        <div>
49          <label htmlFor="firstName" className="block text-sm font-medium">
50            Imię
51          </label>
52          <input
53            id="firstName"
54            type="text"
55            className={`mt-1 block w-full rounded-md ${
56              errors.personalInfo?.firstName ? 'border-red-500' : 'border-gray-300'
57            }`}
58            {...register('personalInfo.firstName')}
59          />
60          {errors.personalInfo?.firstName && (
61            <p className="text-red-500 text-xs mt-1">{errors.personalInfo.firstName.message}</p>
62          )}
63        </div>
64        
65        <div>
66          <label htmlFor="lastName" className="block text-sm font-medium">
67            Nazwisko
68          </label>
69          <input
70            id="lastName"
71            type="text"
72            className={`mt-1 block w-full rounded-md ${
73              errors.personalInfo?.lastName ? 'border-red-500' : 'border-gray-300'
74            }`}
75            {...register('personalInfo.lastName')}
76          />
77          {errors.personalInfo?.lastName && (
78            <p className="text-red-500 text-xs mt-1">{errors.personalInfo.lastName.message}</p>
79          )}
80        </div>
81      </div>
82      
83      <div>
84        <label htmlFor="email" className="block text-sm font-medium">
85          Email
86        </label>
87        <input
88          id="email"
89          type="email"
90          className={`mt-1 block w-full rounded-md ${
91            errors.personalInfo?.email ? 'border-red-500' : 'border-gray-300'
92          }`}
93          {...register('personalInfo.email')}
94        />
95        {errors.personalInfo?.email && (
96          <p className="text-red-500 text-xs mt-1">{errors.personalInfo.email.message}</p>
97        )}
98      </div>
99      
100      <div>
101        <label htmlFor="phoneNumber" className="block text-sm font-medium">
102          Numer telefonu
103        </label>
104        <input
105          id="phoneNumber"
106          type="tel"
107          className={`mt-1 block w-full rounded-md ${
108            errors.personalInfo?.phoneNumber ? 'border-red-500' : 'border-gray-300'
109          }`}
110          {...register('personalInfo.phoneNumber')}
111        />
112        {errors.personalInfo?.phoneNumber && (
113          <p className="text-red-500 text-xs mt-1">{errors.personalInfo.phoneNumber.message}</p>
114        )}
115      </div>
116    </div>
117  );
118}
119
120// Komponent kroku formularza z adresem
121function AddressStep() {
122  const { register, formState: { errors } } = useFormContext<MultiStepFormData>();
123  
124  return (
125    <div className="space-y-4">
126      <h2 className="text-xl font-bold">Adres dostawy</h2>
127      
128      <div>
129        <label htmlFor="street" className="block text-sm font-medium">
130          Ulica i numer
131        </label>
132        <input
133          id="street"
134          type="text"
135          className={`mt-1 block w-full rounded-md ${
136            errors.address?.street ? 'border-red-500' : 'border-gray-300'
137          }`}
138          {...register('address.street')}
139        />
140        {errors.address?.street && (
141          <p className="text-red-500 text-xs mt-1">{errors.address.street.message}</p>
142        )}
143      </div>
144      
145      <div className="grid grid-cols-2 gap-4">
146        <div>
147          <label htmlFor="city" className="block text-sm font-medium">
148            Miasto
149          </label>
150          <input
151            id="city"
152            type="text"
153            className={`mt-1 block w-full rounded-md ${
154              errors.address?.city ? 'border-red-500' : 'border-gray-300'
155            }`}
156            {...register('address.city')}
157          />
158          {errors.address?.city && (
159            <p className="text-red-500 text-xs mt-1">{errors.address.city.message}</p>
160          )}
161        </div>
162        
163        <div>
164          <label htmlFor="postalCode" className="block text-sm font-medium">
165            Kod pocztowy
166          </label>
167          <input
168            id="postalCode"
169            type="text"
170            className={`mt-1 block w-full rounded-md ${
171              errors.address?.postalCode ? 'border-red-500' : 'border-gray-300'
172            }`}
173            {...register('address.postalCode')}
174          />
175          {errors.address?.postalCode && (
176            <p className="text-red-500 text-xs mt-1">{errors.address.postalCode.message}</p>
177          )}
178        </div>
179      </div>
180      
181      <div>
182        <label htmlFor="country" className="block text-sm font-medium">
183          Kraj
184        </label>
185        <select
186          id="country"
187          className={`mt-1 block w-full rounded-md ${
188            errors.address?.country ? 'border-red-500' : 'border-gray-300'
189          }`}
190          {...register('address.country')}
191        >
192          <option value="">Wybierz kraj</option>
193          <option value="Poland">Polska</option>
194          <option value="Germany">Niemcy</option>
195          <option value="France">Francja</option>
196          <option value="UK">Wielka Brytania</option>
197        </select>
198        {errors.address?.country && (
199          <p className="text-red-500 text-xs mt-1">{errors.address.country.message}</p>
200        )}
201      </div>
202    </div>
203  );
204}
205
206// Komponent kroku formularza z płatnością
207function PaymentStep() {
208  const { register, formState: { errors } } = useFormContext<MultiStepFormData>();
209  
210  return (
211    <div className="space-y-4">
212      <h2 className="text-xl font-bold">Metoda płatności</h2>
213      
214      <div>
215        <label htmlFor="cardName" className="block text-sm font-medium">
216          Imię i nazwisko na karcie
217        </label>
218        <input
219          id="cardName"
220          type="text"
221          className={`mt-1 block w-full rounded-md ${
222            errors.payment?.cardName ? 'border-red-500' : 'border-gray-300'
223          }`}
224          {...register('payment.cardName')}
225        />
226        {errors.payment?.cardName && (
227          <p className="text-red-500 text-xs mt-1">{errors.payment.cardName.message}</p>
228        )}
229      </div>
230      
231      <div>
232        <label htmlFor="cardNumber" className="block text-sm font-medium">
233          Numer karty
234        </label>
235        <input
236          id="cardNumber"
237          type="text"
238          className={`mt-1 block w-full rounded-md ${
239            errors.payment?.cardNumber ? 'border-red-500' : 'border-gray-300'
240          }`}
241          {...register('payment.cardNumber')}
242        />
243        {errors.payment?.cardNumber && (
244          <p className="text-red-500 text-xs mt-1">{errors.payment.cardNumber.message}</p>
245        )}
246      </div>
247      
248      <div className="grid grid-cols-2 gap-4">
249        <div>
250          <label htmlFor="expiryDate" className="block text-sm font-medium">
251            Data ważności
252          </label>
253          <input
254            id="expiryDate"
255            type="text"
256            className={`mt-1 block w-full rounded-md ${
257              errors.payment?.expiryDate ? 'border-red-500' : 'border-gray-300'
258            }`}
259            {...register('payment.expiryDate')}
260          />
261          {errors.payment?.expiryDate && (
262            <p className="text-red-500 text-xs mt-1">{errors.payment.expiryDate.message}</p>
263          )}
264        </div>
265        
266        <div>
267          <label htmlFor="cvv" className="block text-sm font-medium">
268            CVV
269          </label>
270          <input
271            id="cvv"
272            type="text"
273            className={`mt-1 block w-full rounded-md ${
274              errors.payment?.cvv ? 'border-red-500' : 'border-gray-300'
275            }`}
276            {...register('payment.cvv')}
277          />
278          {errors.payment?.cvv && (
279            <p className="text-red-500 text-xs mt-1">{errors.payment.cvv.message}</p>
280          )}
281        </div>
282      </div>
283    </div>
284  );
285}
286
287// Komponent podsumowania
288function SummaryStep({ data }: { data: MultiStepFormData }) {
289  return (
290    <div className="space-y-6">
291      <h2 className="text-xl font-bold">Podsumowanie zamówienia</h2>
292      
293      <div className="bg-gray-50 p-4 rounded">
294        <h3 className="font-medium mb-2">Dane osobowe</h3>
295        <p>{data.personalInfo.firstName} {data.personalInfo.lastName}</p>
296        <p>{data.personalInfo.email}</p>
297        <p>Tel: {data.personalInfo.phoneNumber}</p>
298      </div>
299      
300      <div className="bg-gray-50 p-4 rounded">
301        <h3 className="font-medium mb-2">Adres dostawy</h3>
302        <p>{data.address.street}</p>
303        <p>{data.address.postalCode} {data.address.city}</p>
304        <p>{data.address.country}</p>
305      </div>
306      
307      <div className="bg-gray-50 p-4 rounded">
308        <h3 className="font-medium mb-2">Metoda płatności</h3>
309        <p>{data.payment.cardName}</p>
310        <p>Karta: **** **** **** {data.payment.cardNumber.slice(-4)}</p>
311        <p>Ważna do: {data.payment.expiryDate}</p>
312      </div>
313    </div>
314  );
315}
316
317export default function MultiStepForm() {
318  const [currentStep, setCurrentStep] = useState(0);
319  const [isSubmitted, setIsSubmitted] = useState(false);
320  
321  const methods = useForm<MultiStepFormData>({
322    resolver: zodResolver(formSchema),
323    mode: 'onBlur',
324    defaultValues: {
325      personalInfo: {
326        firstName: '',
327        lastName: '',
328        email: '',
329        phoneNumber: ''
330      },
331      address: {
332        street: '',
333        city: '',
334        postalCode: '',
335        country: ''
336      },
337      payment: {
338        cardName: '',
339        cardNumber: '',
340        expiryDate: '',
341        cvv: ''
342      }
343    }
344  });
345  
346  const steps = [
347    { name: 'Dane osobowe', component: <PersonalInfoStep /> },
348    { name: 'Adres', component: <AddressStep /> },
349    { name: 'Płatność', component: <PaymentStep /> },
350    { name: 'Podsumowanie', component: <SummaryStep data={methods.getValues()} /> }
351  ];
352  
353  const onSubmit: SubmitHandler<MultiStepFormData> = async (data) => {
354    // Procesów finalnej wysyłki danych
355    if (currentStep === steps.length - 1) {
356      await new Promise(resolve => setTimeout(resolve, 1500));
357      console.log('Zamówienie złożone:', data);
358      setIsSubmitted(true);
359    } else {
360      // Przechodzimy do następnego kroku
361      setCurrentStep(prev => prev + 1);
362    }
363  };
364  
365  const handlePrevStep = () => {
366    setCurrentStep(prev => Math.max(0, prev - 1));
367  };
368  
369  const validateCurrentStep = async () => {
370    let isValid = false;
371    
372    switch (currentStep) {
373      case 0:
374        isValid = await methods.trigger('personalInfo');
375        break;
376      case 1:
377        isValid = await methods.trigger('address');
378        break;
379      case 2:
380        isValid = await methods.trigger('payment');
381        break;
382      case 3:
383        isValid = true; // Podsumowanie nie wymaga walidacji
384        break;
385    }
386    
387    return isValid;
388  };
389  
390  const handleNextStep = async () => {
391    const isValid = await validateCurrentStep();
392    
393    if (isValid) {
394      setCurrentStep(prev => Math.min(steps.length - 1, prev + 1));
395    }
396  };
397  
398  return (
399    <div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-md">
400      {isSubmitted ? (
401        <div className="text-center py-8">
402          <svg className="w-16 h-16 text-green-500 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
403            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
404          </svg>
405          <h2 className="text-2xl font-bold mb-2">Zamówienie przyjęte!</h2>
406          <p className="text-gray-600 mb-4">Dziękujemy za złożenie zamówienia. Szczegóły wysłaliśmy na podany adres email.</p>
407          <button 
408            onClick={() => {
409              methods.reset();
410              setCurrentStep(0);
411              setIsSubmitted(false);
412            }}
413            className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
414          >
415            Złóż nowe zamówienie
416          </button>
417        </div>
418      ) : (
419        <>
420          {/* Pasek postępu */}
421          <div className="mb-8">
422            <div className="flex justify-between mb-2">
423              {steps.map((step, index) => (
424                <div key={index} className="flex flex-col items-center">
425                  <div 
426                    className={`w-8 h-8 flex items-center justify-center rounded-full ${index <= currentStep ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}
427                  >
428                    {index + 1}
429                  </div>
430                  <span className="text-xs mt-1">{step.name}</span>
431                </div>
432              ))}
433            </div>
434            <div className="relative w-full h-2 bg-gray-200 rounded-full overflow-hidden">
435              <div 
436                className="absolute h-full bg-blue-600 transition-all duration-300"
437                style={{ width: `${(currentStep / (steps.length - 1)) * 100}%` }}
438              ></div>
439            </div>
440          </div>
441          
442          <FormProvider {...methods}>
443            <form onSubmit={methods.handleSubmit(onSubmit)} className="space-y-8">
444              {steps[currentStep].component}
445              
446              <div className="flex justify-between mt-8">
447                <button
448                  type="button"
449                  onClick={handlePrevStep}
450                  disabled={currentStep === 0}
451                  className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
452                >
453                  Wstecz
454                </button>
455                
456                {currentStep < steps.length - 1 ? (
457                  <button
458                    type="button"
459                    onClick={handleNextStep}
460                    className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
461                  >
462                    Dalej
463                  </button>
464                ) : (
465                  <button
466                    type="submit"
467                    className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
468                  >
469                    {methods.formState.isSubmitting ? 'Przetwarzanie...' : 'Złóż zamówienie'}
470                  </button>
471                )}
472              </div>
473            </form>
474          </FormProvider>
475        </>
476      )}
477    </div>
478  );
479}

Główne elementy wielostopniowego formularza:

  1. FormProvider - dostarcza kontekst formularza dla zagnieżdżonych komponentów
  2. useFormContext - pozwala komponentom potomnym na dostęp do metod formularza
  3. Zagnieżdżone pola - przy użyciu notacji kropkowej (np.
    personalInfo.firstName
    )
  4. Walidacja po krokach - z wykorzystaniem metody
    trigger
    do walidacji konkretnych części formularza

Integracja z Server Actions w Next.js

React Hook Form można łatwo połączyć z Server Actions w Next.js:

1// app/newsletter/page.tsx
2'use client';
3
4import { useForm, SubmitHandler } from 'react-hook-form';
5import { zodResolver } from '@hookform/resolvers/zod';
6import { z } from 'zod';
7import { useState } from 'react';
8import { subscribeToNewsletter } from './actions';
9
10const newsletterSchema = z.object({
11  email: z.string().email('Podaj prawidłowy adres email'),
12  firstName: z.string().min(1, 'Imię jest wymagane'),
13  preferences: z.object({
14    marketing: z.boolean().optional(),
15    updates: z.boolean().optional(),
16    news: z.boolean().optional()
17  }).refine(prefs => prefs.marketing || prefs.updates || prefs.news, {
18    message: 'Wybierz co najmniej jedną preferencję',
19    path: ['_errors']
20  })
21});
22
23type NewsletterFormInputs = z.infer<typeof newsletterSchema>;
24
25export default function NewsletterForm() {
26  const [submitStatus, setSubmitStatus] = useState<{ success: boolean; message: string } | null>(null);
27  
28  const { 
29    register, 
30    handleSubmit, 
31    formState: { errors, isSubmitting },
32    reset
33  } = useForm<NewsletterFormInputs>({
34    resolver: zodResolver(newsletterSchema),
35    defaultValues: {
36      email: '',
37      firstName: '',
38      preferences: {
39        marketing: false,
40        updates: false,
41        news: false
42      }
43    }
44  });
45  
46  const onSubmit: SubmitHandler<NewsletterFormInputs> = async (data) => {
47    try {
48      // Wywołanie Server Action
49      const result = await subscribeToNewsletter(data);
50      
51      setSubmitStatus({
52        success: result.success,
53        message: result.message
54      });
55      
56      if (result.success) {
57        reset(); // Resetowanie formularza po udanym wysyłaniu
58      }
59    } catch (error) {
60      setSubmitStatus({
61        success: false,
62        message: 'Wystąpił nieoczekiwany błąd. Spróbuj ponownie później.'
63      });
64    }
65  };
66  
67  return (
68    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
69      <h2 className="text-2xl font-bold mb-6">Zapisz się do newslettera</h2>
70      
71      {submitStatus && (
72        <div className={`mb-4 p-3 rounded ${submitStatus.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
73          {submitStatus.message}
74        </div>
75      )}
76      
77      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
78        <div>
79          <label htmlFor="email" className="block text-sm font-medium">
80            Email
81          </label>
82          <input
83            id="email"
84            type="email"
85            className={`mt-1 block w-full rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
86            {...register('email')}
87          />
88          {errors.email && (
89            <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
90          )}
91        </div>
92        
93        <div>
94          <label htmlFor="firstName" className="block text-sm font-medium">
95            Imię
96          </label>
97          <input
98            id="firstName"
99            type="text"
100            className={`mt-1 block w-full rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
101            {...register('firstName')}
102          />
103          {errors.firstName && (
104            <p className="text-red-500 text-xs mt-1">{errors.firstName.message}</p>
105          )}
106        </div>
107        
108        <div>
109          <label className="block text-sm font-medium mb-2">
110            Preferencje newslettera
111          </label>
112          
113          <div className="space-y-2">
114            <label className="flex items-center">
115              <input
116                type="checkbox"
117                className="h-4 w-4 rounded border-gray-300 mr-2"
118                {...register('preferences.marketing')}
119              />
120              Informacje marketingowe i promocje
121            </label>
122            
123            <label className="flex items-center">
124              <input
125                type="checkbox"
126                className="h-4 w-4 rounded border-gray-300 mr-2"
127                {...register('preferences.updates')}
128              />
129              Aktualizacje produktów i usług
130            </label>
131            
132            <label className="flex items-center">
133              <input
134                type="checkbox"
135                className="h-4 w-4 rounded border-gray-300 mr-2"
136                {...register('preferences.news')}
137              />
138              Wiadomości branżowe
139            </label>
140          </div>
141          
142          {errors.preferences?.['_errors'] && (
143            <p className="text-red-500 text-xs mt-1">{errors.preferences['_errors'].join(', ')}</p>
144          )}
145        </div>
146        
147        <button
148          type="submit"
149          disabled={isSubmitting}
150          className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
151        >
152          {isSubmitting ? 'Wysyłanie...' : 'Zapisz się'}
153        </button>
154      </form>
155    </div>
156  );
157}

I plik z Server Action:

1// app/newsletter/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6const newsletterSchema = z.object({
7  email: z.string().email(),
8  firstName: z.string().min(1),
9  preferences: z.object({
10    marketing: z.boolean().optional(),
11    updates: z.boolean().optional(),
12    news: z.boolean().optional()
13  })
14});
15
16type NewsletterData = z.infer<typeof newsletterSchema>;
17
18export async function subscribeToNewsletter(data: NewsletterData) {
19  // Walidacja danych na serwerze dla bezpieczeństwa
20  const validationResult = newsletterSchema.safeParse(data);
21  
22  if (!validationResult.success) {
23    return {
24      success: false,
25      message: 'Nieprawidłowe dane. Sprawdź formularz i spróbuj ponownie.'
26    };
27  }
28  
29  try {
30    // Symulacja opóźnienia API
31    await new Promise(resolve => setTimeout(resolve, 1000));
32    
33    // Tutaj byłby rzeczywisty kod zapisujący użytkownika do newslettera
34    // np. wywołanie API, zapis do bazy danych itp.
35    console.log('Zapisano do newslettera:', validationResult.data);
36    
37    return {
38      success: true,
39      message: 'Dziękujemy za zapisanie się do newslettera!'
40    };
41  } catch (error) {
42    console.error('Błąd podczas zapisywania do newslettera:', error);
43    return {
44      success: false,
45      message: 'Wystąpił błąd podczas przetwarzania. Spróbuj ponownie później.'
46    };
47  }
48}

Główne elementy integracji React Hook Form z Server Actions:

  1. Typowanie wspólne - ten sam typ danych jest używany zarówno po stronie klienta, jak i serwera
  2. Walidacja dwustronna - dane są walidowane zarówno przez React Hook Form, jak i na serwerze
  3. Obsługa błędów - kompleksowa obsługa błędów po obu stronach

Dobre praktyki i wskazówki

  1. Używaj TypeScript - zapewnia to statyczną typizację i umożliwia wykrywanie błędów na etapie kompilacji

  2. Rozdzielaj komponenty - dziel duże formularze na mniejsze, zarządzalne komponenty

  3. Korzystaj z mode: 'onBlur' - waliduj formularze po opuszczeniu pola, a nie przy każdej zmianie:

    1const { register } = useForm({ mode: 'onBlur' });
  4. Używaj shouldUnregister - dla dynamicznych pól formularza, które mogą pojawiać się i znikać:

    1const { register } = useForm({ shouldUnregister: true });
  5. Wykorzystaj setValue dla złożonych przypadków - do programowego ustawiania wartości:

    1setValue('field', value, { shouldValidate: true, shouldDirty: true });
  6. Używaj transform do formatowania danych:

    1register('amount', {
    2  setValueAs: value => parseFloat(value) || 0
    3});

Podsumowanie

React Hook Form to potężne narzędzie, które znacząco upraszcza tworzenie i zarządzanie formularzami w React. Główne zalety:

  • Wydajność - minimalne przerenderowania komponentów
  • Mniejsza ilość kodu - mniej boilerplate'u w porównaniu do natywnych kontrolowanych komponentów
  • Elastyczność - działa zarówno z kontrolowanymi, jak i niekontrolowanymi komponentami
  • Zaawansowana walidacja - zwłaszcza w połączeniu z bibliotekami takimi jak Zod czy Yup
  • TypeScript - pełne wsparcie dla typowania

Podłączając React Hook Form do Server Actions w Next.js, możesz tworzyć wydajne, bezpieczne i przyjazne dla użytkownika formularze, które zachowują się dobrze zarówno z włączonym JavaScript, jak i bez niego (dzięki progresywnemu ulepszaniu w Next.js).

Właściwe zarządzanie formularzami jest kluczowe dla tworzenia wysokiej jakości aplikacji webowych, a React Hook Form sprawia, że proces ten staje się znacznie prostszy i przyjemniejszy zarówno dla developerów, jak i użytkowników końcowych.

Ir a CodeWorlds