Forms are an integral part of most web applications, but effectively managing their state, validation, and error handling can be challenging. React Hook Form is one of the most popular libraries in the React ecosystem, designed to solve common form-related problems while maintaining high performance and low code complexity.
Before diving into implementation details, let's look at the main advantages of React Hook Form compared to native React solutions:
Let's start with a simple login form example:
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 // In a real application, we would call the API here
18 console.log('Login data:', data);
19
20 // Simulate network delay
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 is required',
36 pattern: {
37 value: /^S+@S+.S+$/,
38 message: 'Invalid email format'
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 Password
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: 'Password jest wymagane',
57 minLength: {
58 value: 6,
59 message: 'Password must be at least 6 characters'
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 Remember me
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 ? 'Logging in...' : 'Log in'}
86 </button>
87 </form>
88 );
89}The main React Hook Form elements in the above example:
React Hook Form integrates excellently with validation libraries such as Zod, enabling the creation of robust validation schemas:
1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// Schema definition with Zod
6const signupSchema = z.object({
7 username: z.string()
8 .min(3, 'Username must be at least 3 characters')
9 .max(20, 'Username can be at most 20 characters')
10 .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, digits, and underscores'),
11 email: z.string()
12 .email('Please provide a valid email address'),
13 password: z.string()
14 .min(8, 'Password must be at least 8 characters')
15 .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
16 .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
17 .regex(/[0-9]/, 'Password must contain at least one digit'),
18 confirmPassword: z.string(),
19 terms: z.boolean().refine(val => val === true, {
20 message: 'You must accept the terms',
21 }),
22}).refine(data => data.password === data.confirmPassword, {
23 message: 'Passwords do not match',
24 path: ['confirmPassword'],
25});
26
27// Type resulting from the schema
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 // Simulate sending data to API
50 await new Promise(resolve => setTimeout(resolve, 1500));
51 console.log('Registration data:', data);
52
53 // Reset form after successful submission
54 reset();
55 } catch (error) {
56 console.error('Registration error:', 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">Registration</h2>
63
64 {isSubmitSuccessful && (
65 <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
66 Registration completed successfully!
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 Username
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 Password
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 Confirm password
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 I accept the terms of service
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 ? 'Registering...' : 'Sign up'}
152 </button>
153 </form>
154 </div>
155 );
156}Key elements of this example:
React Hook Form handles dynamic forms excellently, in which fields can appear or disappear depending on conditions:
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 // We use useWatch to observe changes in the selected delivery method
33 const deliveryMethod = useWatch({
34 control,
35 name: 'deliveryMethod',
36 defaultValue: 'standard',
37 });
38
39 // We check if we need a shipping address
40 const needsShippingAddress = deliveryMethod === 'standard' || deliveryMethod === 'express';
41
42 const onSubmit: SubmitHandler<CheckoutInputs> = async (data) => {
43 // Simulate API delay
44 await new Promise(resolve => setTimeout(resolve, 1000));
45 console.log('Order data:', 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">Order Checkout</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 First name
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: 'Name is required' })}
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 Last name
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: 'Last name is required' })}
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 is required',
95 pattern: {
96 value: /^S+@S+.S+$/,
97 message: 'Invalid email format'
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 Delivery method
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 Standard delivery (2-3 business days)
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 Express delivery (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 Personal pickup
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">Shipping address</h3>
147
148 <div>
149 <label htmlFor="address" className="block text-sm font-medium">
150 Address
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: 'Address is required' })}
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 City
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: 'City is required' })}
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 Postal code
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: 'Postal code is required',
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 Pickup point
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: 'Select pickup point' })}
212 >
213 <option value="">Select pickup point</option>
214 <option value="warszawa-centrum">Warsaw - Center</option>
215 <option value="warszawa-mokotow">Warsaw - Mokotow</option>
216 <option value="krakow-centrum">Krakow - Center</option>
217 <option value="poznan-centrum">Poznan - Center</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 ? 'Processing...' : 'Place order'}
231 </button>
232 </form>
233 </div>
234 );
235}Key elements of a dynamic form:
React Hook Form also works great in multi-step forms:
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// Personal data schema
7const personalInfoSchema = z.object({
8 firstName: z.string().min(1, 'Name is required'),
9 lastName: z.string().min(1, 'Last name is required'),
10 email: z.string().email('Invalid email format'),
11 phoneNumber: z.string().regex(/^[0-9]{9}$/, 'Phone number must be 9 digits')
12});
13
14// Address schema
15const addressSchema = z.object({
16 street: z.string().min(1, 'Street is required'),
17 city: z.string().min(1, 'City is required'),
18 postalCode: z.string().regex(/^d{2}-d{3}$/, 'Postal code must be in 00-000 format'),
19 country: z.string().min(1, 'Country is required')
20});
21
22// Payment schema
23const paymentSchema = z.object({
24 cardName: z.string().min(1, 'Name on card is required'),
25 cardNumber: z.string().regex(/^[0-9]{16}$/, 'Card number must be 16 digits'),
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 must be 3 or 4 digits')
28});
29
30// Combined schema
31const formSchema = z.object({
32 personalInfo: personalInfoSchema,
33 address: addressSchema,
34 payment: paymentSchema
35});
36
37type MultiStepFormData = z.infer<typeof formSchema>;
38
39// Personal data form step component
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">Personal data</h2>
46
47 <div className="grid grid-cols-2 gap-4">
48 <div>
49 <label htmlFor="firstName" className="block text-sm font-medium">
50 First name
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 Last name
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 Phone number
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// Address form step component
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">Shipping address</h2>
127
128 <div>
129 <label htmlFor="street" className="block text-sm font-medium">
130 Street and number
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 City
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 Postal code
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 Country
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="">Select country</option>
193 <option value="Poland">Poland</option>
194 <option value="Germany">Germany</option>
195 <option value="France">France</option>
196 <option value="UK">United Kingdom</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// Payment form step component
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">Payment method</h2>
213
214 <div>
215 <label htmlFor="cardName" className="block text-sm font-medium">
216 Name on card
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 Card number
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 Expiry date
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// Summary component
288function SummaryStep({ data }: { data: MultiStepFormData }) {
289 return (
290 <div className="space-y-6">
291 <h2 className="text-xl font-bold">Order summary</h2>
292
293 <div className="bg-gray-50 p-4 rounded">
294 <h3 className="font-medium mb-2">Personal data</h3>
295 <p>{data.personalInfo.firstName} {data.personalInfo.lastName}</p>
296 <p>{data.personalInfo.email}</p>
297 <p>Phone: {data.personalInfo.phoneNumber}</p>
298 </div>
299
300 <div className="bg-gray-50 p-4 rounded">
301 <h3 className="font-medium mb-2">Shipping address</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">Payment method</h3>
309 <p>{data.payment.cardName}</p>
310 <p>Card: **** **** **** {data.payment.cardNumber.slice(-4)}</p>
311 <p>Valid until: {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: 'Personal data', component: <PersonalInfoStep /> },
348 { name: 'Address', component: <AddressStep /> },
349 { name: 'Payment', component: <PaymentStep /> },
350 { name: 'Summary', component: <SummaryStep data={methods.getValues()} /> }
351 ];
352
353 const onSubmit: SubmitHandler<MultiStepFormData> = async (data) => {
354 // Final data submission processing
355 if (currentStep === steps.length - 1) {
356 await new Promise(resolve => setTimeout(resolve, 1500));
357 console.log('Order placed:', data);
358 setIsSubmitted(true);
359 } else {
360 // Move to the next step
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; // Summary doesn't require validation
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">Order accepted!</h2>
406 <p className="text-gray-600 mb-4">Thank you for placing your order. We have sent the details to the provided email address.</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 Place new order
416 </button>
417 </div>
418 ) : (
419 <>
420 {/* Progress bar */}
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 Back
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 Next
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 ? 'Processing...' : 'Place order'}
470 </button>
471 )}
472 </div>
473 </form>
474 </FormProvider>
475 </>
476 )}
477 </div>
478 );
479}Main elements of a multi-step form:
personalInfo.firstName)trigger method to validate specific parts of the formReact Hook Form can easily be combined with Server Actions in 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('Please provide a valid email address'),
12 firstName: z.string().min(1, 'Name is required'),
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: 'Select at least one preference',
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 // Call 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(); // Reset form after successful submission
58 }
59 } catch (error) {
60 setSubmitStatus({
61 success: false,
62 message: 'An unexpected error occurred. Please try again later.'
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">Subscribe to newsletter</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 First name
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 Newsletter preferences
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 Marketing information and promotions
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 Product and service updates
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 Industry news
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 ? 'Sending...' : 'Subscribe'}
153 </button>
154 </form>
155 </div>
156 );
157}And the Server Action file:
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 // Validate data on the server for security
20 const validationResult = newsletterSchema.safeParse(data);
21
22 if (!validationResult.success) {
23 return {
24 success: false,
25 message: 'Invalid data. Check the form and try again.'
26 };
27 }
28
29 try {
30 // Simulate API delay
31 await new Promise(resolve => setTimeout(resolve, 1000));
32
33 // Here would be the actual code to subscribe the user to the newsletter
34 // e.g. API call, save to database etc.
35 console.log('Subscribed to newsletter:', validationResult.data);
36
37 return {
38 success: true,
39 message: 'Thank you for subscribing to the newsletter!'
40 };
41 } catch (error) {
42 console.error('Error while subscribing to newsletter:', error);
43 return {
44 success: false,
45 message: 'An error occurred during processing. Please try again later.'
46 };
47 }
48}Main elements of integrating React Hook Form with Server Actions:
Use TypeScript - it provides static typing and enables compile-time error detection
Split components - divide large forms into smaller, manageable components
Use mode: 'onBlur' - validate forms when leaving a field rather than on every change:
1const { register } = useForm({ mode: 'onBlur' });Use shouldUnregister - for dynamic form fields that may appear and disappear:
1const { register } = useForm({ shouldUnregister: true });Use setValue for complex cases - for programmatically setting values:
1setValue('field', value, { shouldValidate: true, shouldDirty: true });Use transform for formatting data:
1register('amount', {
2 setValueAs: value => parseFloat(value) || 0
3});React Hook Form is a powerful tool that significantly simplifies creating and managing forms in React. Main advantages:
By connecting React Hook Form with Server Actions in Next.js, you can build efficient, secure, and user-friendly forms that behave well both with JavaScript enabled and disabled (thanks to progressive enhancement in Next.js).
Proper form management is crucial for building high-quality web applications, and React Hook Form makes this process significantly simpler and more enjoyable for both developers and end users.