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.
Przed zgłębieniem szczegółów implementacji, przyjrzyjmy się głównym zaletom React Hook Form w porównaniu do natywnych rozwiązań React:
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:
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:
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:
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:
personalInfo.firstName)trigger do walidacji konkretnych części formularzaReact 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:
Używaj TypeScript - zapewnia to statyczną typizację i umożliwia wykrywanie błędów na etapie kompilacji
Rozdzielaj komponenty - dziel duże formularze na mniejsze, zarządzalne komponenty
Korzystaj z mode: 'onBlur' - waliduj formularze po opuszczeniu pola, a nie przy każdej zmianie:
1const { register } = useForm({ mode: 'onBlur' });Używaj shouldUnregister - dla dynamicznych pól formularza, które mogą pojawiać się i znikać:
1const { register } = useForm({ shouldUnregister: true });Wykorzystaj setValue dla złożonych przypadków - do programowego ustawiania wartości:
1setValue('field', value, { shouldValidate: true, shouldDirty: true });Używaj transform do formatowania danych:
1register('amount', {
2 setValueAs: value => parseFloat(value) || 0
3});React Hook Form to potężne narzędzie, które znacząco upraszcza tworzenie i zarządzanie formularzami w React. Główne zalety:
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.