We use cookies to enhance your experience on the site
CodeWorlds

React Hook Form and Form State Management

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.

Why Use React Hook Form?

Before diving into implementation details, let's look at the main advantages of React Hook Form compared to native React solutions:

  1. Minimal rendering - the library minimizes unnecessary re-renders, resulting in better performance
  2. Less boilerplate code - compared to native controlled components
  3. Easy integration - works with both controlled and uncontrolled components
  4. Advanced validation - extensive validation capabilities, integration with Yup, Zod, Joi, and other libraries
  5. TypeScript typing - full TypeScript support
  6. Performance - optimized for performance, even in complex forms

React Hook Form Basics

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:

  1. useForm - the main hook that provides methods for managing the form
  2. register - function that registers form fields in React Hook Form
  3. handleSubmit - function that wraps our form submission handler
  4. formState - object containing information about the form state, including errors and submission status

Advanced Validation with Zod

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:

  1. zodResolver - adapter that integrates Zod with React Hook Form
  2. Advanced validation rules - complex regular expressions, minimum length, etc.
  3. refine function - for custom validation conditions, such as comparing passwords
  4. z.infer<typeof schema> - automatic type generation from the schema definition

Dynamic Forms and Conditional Fields

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:

  1. useWatch - allows observing changes to selected form fields without triggering re-renders of the entire form
  2. Conditionally rendered sections - depending on the selected delivery method
  3. Conditional validation - different validation rules for different form sections

Multi-step Forms

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:

  1. FormProvider - provides form context for nested components
  2. useFormContext - allows child components to access form methods
  3. Nested fields - using dot notation (e.g.
    personalInfo.firstName
    )
  4. Step-by-step validation - using the
    trigger
    method to validate specific parts of the form

Integration with Server Actions in Next.js

React 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:

  1. Shared typing - the same data type is used on both client and server side
  2. Two-way validation - data is validated both by React Hook Form and on the server
  3. Error handling - comprehensive error handling on both sides

Best Practices and Tips

  1. Use TypeScript - it provides static typing and enables compile-time error detection

  2. Split components - divide large forms into smaller, manageable components

  3. Use mode: 'onBlur' - validate forms when leaving a field rather than on every change:

    1const { register } = useForm({ mode: 'onBlur' });
  4. Use shouldUnregister - for dynamic form fields that may appear and disappear:

    1const { register } = useForm({ shouldUnregister: true });
  5. Use setValue for complex cases - for programmatically setting values:

    1setValue('field', value, { shouldValidate: true, shouldDirty: true });
  6. Use transform for formatting data:

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

Summary

React Hook Form is a powerful tool that significantly simplifies creating and managing forms in React. Main advantages:

  • Performance - minimal component re-renders
  • Less code - less boilerplate compared to native controlled components
  • Flexibility - works with both controlled and uncontrolled components
  • Advanced validation - especially combined with libraries like Zod or Yup
  • TypeScript - full typing support

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.

Go to CodeWorlds