We use cookies to enhance your experience on the site
CodeWorlds

Multi-step forms and flows

Creating complex, multi-step forms is a common task in web applications. Such forms are frequently used for registration processes, reservations, orders, or product configuration. In this module, we'll look at various strategies for implementing multi-step forms in React and Next.js environments.

Why split forms into stages?

Splitting a long form into smaller steps brings many benefits:

  1. Better user experience - shorter forms are less overwhelming and reduce the risk of abandonment
  2. Greater clarity - users can more easily focus on one aspect of the process at a time
  3. Progressive data collection - we can validate each step separately and adjust subsequent stages based on earlier responses
  4. Logical organization - grouping related form fields improves structure and understanding
  5. Partial save capability - users can save their progress and return later

Basic approaches to multi-step forms

There are several main approaches to implementing multi-step forms:

1. Component-state based approach

The simplest way is to store the current step and all form data in component state:

1'use client';
2
3import { useState, FormEvent } from 'react';
4
5// Data types for individual steps
6interface UserDetails {
7  firstName: string;
8  lastName: string;
9  email: string;
10}
11
12interface AddressDetails {
13  street: string;
14  city: string;
15  zipCode: string;
16  country: string;
17}
18
19interface PaymentDetails {
20  cardNumber: string;
21  expiryDate: string;
22  cvv: string;
23}
24
25// Combined form data
26interface FormData {
27  user: UserDetails;
28  address: AddressDetails;
29  payment: PaymentDetails;
30}
31
32export default function MultiStepForm() {
33  // Current step state
34  const [step, setStep] = useState(1);
35
36  // Form data state
37  const [formData, setFormData] = useState<FormData>({
38    user: {
39      firstName: '',
40      lastName: '',
41      email: ''
42    },
43    address: {
44      street: '',
45      city: '',
46      zipCode: '',
47      country: ''
48    },
49    payment: {
50      cardNumber: '',
51      expiryDate: '',
52      cvv: ''
53    }
54  });
55  
56  // Validation state
57  const [errors, setErrors] = useState<Record<string, string>>({});
58
59  // Handle form field changes
60  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
61    const { name, value } = e.target;
62
63    // Determine which step the field belongs to
64    if (['firstName', 'lastName', 'email'].includes(name)) {
65      setFormData({
66        ...formData,
67        user: {
68          ...formData.user,
69          [name]: value
70        }
71      });
72    } else if (['street', 'city', 'zipCode', 'country'].includes(name)) {
73      setFormData({
74        ...formData,
75        address: {
76          ...formData.address,
77          [name]: value
78        }
79      });
80    } else if (['cardNumber', 'expiryDate', 'cvv'].includes(name)) {
81      setFormData({
82        ...formData,
83        payment: {
84          ...formData.payment,
85          [name]: value
86        }
87      });
88    }
89    
90    // Remove error after field edit
91    if (errors[name]) {
92      setErrors({
93        ...errors,
94        [name]: ''
95      });
96    }
97  }
98
99  // Validate form data for each step
100  function validateStep(stepNumber: number): boolean {
101    const newErrors: Record<string, string> = {};
102
103    if (stepNumber === 1) {
104      // Validate user data
105      if (!formData.user.firstName.trim()) {
106        newErrors.firstName = 'Name is required';
107      }
108      
109      if (!formData.user.lastName.trim()) {
110        newErrors.lastName = 'Last name is required';
111      }
112      
113      if (!formData.user.email.trim()) {
114        newErrors.email = 'Email is required';
115      } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i.test(formData.user.email)) {
116        newErrors.email = 'Invalid email address format';
117      }
118    } else if (stepNumber === 2) {
119      // Address validation
120      if (!formData.address.street.trim()) {
121        newErrors.street = 'Street is required';
122      }
123      
124      if (!formData.address.city.trim()) {
125        newErrors.city = 'City is required';
126      }
127      
128      if (!formData.address.zipCode.trim()) {
129        newErrors.zipCode = 'Postal code is required';
130      }
131      
132      if (!formData.address.country.trim()) {
133        newErrors.country = 'Country is required';
134      }
135    } else if (stepNumber === 3) {
136      // Payment validation
137      if (!formData.payment.cardNumber.trim()) {
138        newErrors.cardNumber = 'Card number is required';
139      } else if (!/^d{16}$/.test(formData.payment.cardNumber.replace(/s/g, ''))) {
140        newErrors.cardNumber = 'Invalid card number';
141      }
142
143      if (!formData.payment.expiryDate.trim()) {
144        newErrors.expiryDate = 'Expiry date is required';
145      }
146
147      if (!formData.payment.cvv.trim()) {
148        newErrors.cvv = 'CVV code is required';
149      } else if (!/^d{3,4}$/.test(formData.payment.cvv)) {
150        newErrors.cvv = 'Invalid CVV code';
151      }
152    }
153
154    setErrors(newErrors);
155    return Object.keys(newErrors).length === 0;
156  }
157
158  // Move to next step
159  function handleNext() {
160    const isValid = validateStep(step);
161    
162    if (isValid) {
163      setStep(step + 1);
164    }
165  }
166  
167  // Return to previous step
168  function handlePrevious() {
169    setStep(step - 1);
170  }
171  
172  // Submit the entire form
173  function handleSubmit(e: FormEvent) {
174    e.preventDefault();
175
176    const isValid = validateStep(step);
177
178    if (isValid) {
179      // Code for sending data to the API
180      console.log('Submitting form data:', formData);
181
182      // Example API call (in a real application)
183      // await fetch('/api/checkout', {
184      //   method: 'POST',
185      //   headers: { 'Content-Type': 'application/json' },
186      //   body: JSON.stringify(formData)
187      // });
188
189      // Optionally: redirect to confirmation page
190      // router.push('/checkout/success');
191    }
192  }
193
194  // Render the appropriate step
195  function renderStep() {
196    switch (step) {
197      case 1:
198        return (
199          <div className="space-y-4">
200            <h2 className="text-xl font-semibold">Personal data</h2>
201            
202            <div>
203              <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
204                First name
205              </label>
206              <input
207                type="text"
208                id="firstName"
209                name="firstName"
210                value={formData.user.firstName}
211                onChange={handleChange}
212                className={`mt-1 block w-full rounded-md border ${
213                  errors.firstName ? 'border-red-500' : 'border-gray-300'
214                } px-3 py-2`}
215              />
216              {errors.firstName && (
217                <p className="mt-1 text-sm text-red-600">{errors.firstName}</p>
218              )}
219            </div>
220            
221            <div>
222              <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
223                Last name
224              </label>
225              <input
226                type="text"
227                id="lastName"
228                name="lastName"
229                value={formData.user.lastName}
230                onChange={handleChange}
231                className={`mt-1 block w-full rounded-md border ${
232                  errors.lastName ? 'border-red-500' : 'border-gray-300'
233                } px-3 py-2`}
234              />
235              {errors.lastName && (
236                <p className="mt-1 text-sm text-red-600">{errors.lastName}</p>
237              )}
238            </div>
239            
240            <div>
241              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
242                Email
243              </label>
244              <input
245                type="email"
246                id="email"
247                name="email"
248                value={formData.user.email}
249                onChange={handleChange}
250                className={`mt-1 block w-full rounded-md border ${
251                  errors.email ? 'border-red-500' : 'border-gray-300'
252                } px-3 py-2`}
253              />
254              {errors.email && (
255                <p className="mt-1 text-sm text-red-600">{errors.email}</p>
256              )}
257            </div>
258            
259            <div className="flex justify-end">
260              <button
261                type="button"
262                onClick={handleNext}
263                className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
264              >
265                Next
266              </button>
267            </div>
268          </div>
269        );
270        
271      case 2:
272        return (
273          <div className="space-y-4">
274            <h2 className="text-xl font-semibold">Shipping address</h2>
275            
276            <div>
277              <label htmlFor="street" className="block text-sm font-medium text-gray-700">
278                Street and number
279              </label>
280              <input
281                type="text"
282                id="street"
283                name="street"
284                value={formData.address.street}
285                onChange={handleChange}
286                className={`mt-1 block w-full rounded-md border ${
287                  errors.street ? 'border-red-500' : 'border-gray-300'
288                } px-3 py-2`}
289              />
290              {errors.street && (
291                <p className="mt-1 text-sm text-red-600">{errors.street}</p>
292              )}
293            </div>
294            
295            <div>
296              <label htmlFor="city" className="block text-sm font-medium text-gray-700">
297                City
298              </label>
299              <input
300                type="text"
301                id="city"
302                name="city"
303                value={formData.address.city}
304                onChange={handleChange}
305                className={`mt-1 block w-full rounded-md border ${
306                  errors.city ? 'border-red-500' : 'border-gray-300'
307                } px-3 py-2`}
308              />
309              {errors.city && (
310                <p className="mt-1 text-sm text-red-600">{errors.city}</p>
311              )}
312            </div>
313            
314            <div>
315              <label htmlFor="zipCode" className="block text-sm font-medium text-gray-700">
316                Postal code
317              </label>
318              <input
319                type="text"
320                id="zipCode"
321                name="zipCode"
322                value={formData.address.zipCode}
323                onChange={handleChange}
324                className={`mt-1 block w-full rounded-md border ${
325                  errors.zipCode ? 'border-red-500' : 'border-gray-300'
326                } px-3 py-2`}
327              />
328              {errors.zipCode && (
329                <p className="mt-1 text-sm text-red-600">{errors.zipCode}</p>
330              )}
331            </div>
332            
333            <div>
334              <label htmlFor="country" className="block text-sm font-medium text-gray-700">
335                Country
336              </label>
337              <input
338                type="text"
339                id="country"
340                name="country"
341                value={formData.address.country}
342                onChange={handleChange}
343                className={`mt-1 block w-full rounded-md border ${
344                  errors.country ? 'border-red-500' : 'border-gray-300'
345                } px-3 py-2`}
346              />
347              {errors.country && (
348                <p className="mt-1 text-sm text-red-600">{errors.country}</p>
349              )}
350            </div>
351            
352            <div className="flex justify-between">
353              <button
354                type="button"
355                onClick={handlePrevious}
356                className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
357              >
358                Back
359              </button>
360              <button
361                type="button"
362                onClick={handleNext}
363                className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
364              >
365                Next
366              </button>
367            </div>
368          </div>
369        );
370        
371      case 3:
372        return (
373          <div className="space-y-4">
374            <h2 className="text-xl font-semibold">Payment details</h2>
375            
376            <div>
377              <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
378                Card number
379              </label>
380              <input
381                type="text"
382                id="cardNumber"
383                name="cardNumber"
384                value={formData.payment.cardNumber}
385                onChange={handleChange}
386                className={`mt-1 block w-full rounded-md border ${
387                  errors.cardNumber ? 'border-red-500' : 'border-gray-300'
388                } px-3 py-2`}
389              />
390              {errors.cardNumber && (
391                <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
392              )}
393            </div>
394            
395            <div className="grid grid-cols-2 gap-4">
396              <div>
397                <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">
398                  Expiry date
399                </label>
400                <input
401                  type="text"
402                  id="expiryDate"
403                  name="expiryDate"
404                  value={formData.payment.expiryDate}
405                  onChange={handleChange}
406                  className={`mt-1 block w-full rounded-md border ${
407                    errors.expiryDate ? 'border-red-500' : 'border-gray-300'
408                  } px-3 py-2`}
409                />
410                {errors.expiryDate && (
411                  <p className="mt-1 text-sm text-red-600">{errors.expiryDate}</p>
412                )}
413              </div>
414              
415              <div>
416                <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">
417                  CVV
418                </label>
419                <input
420                  type="text"
421                  id="cvv"
422                  name="cvv"
423                  value={formData.payment.cvv}
424                  onChange={handleChange}
425                  className={`mt-1 block w-full rounded-md border ${
426                    errors.cvv ? 'border-red-500' : 'border-gray-300'
427                  } px-3 py-2`}
428                />
429                {errors.cvv && (
430                  <p className="mt-1 text-sm text-red-600">{errors.cvv}</p>
431                )}
432              </div>
433            </div>
434            
435            <div className="flex justify-between">
436              <button
437                type="button"
438                onClick={handlePrevious}
439                className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
440              >
441                Back
442              </button>
443              <button
444                type="submit"
445                onClick={handleSubmit}
446                className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
447              >
448                Place order
449              </button>
450            </div>
451          </div>
452        );
453        
454      default:
455        return null;
456    }
457  }
458  
459  return (
460    <div className="max-w-2xl mx-auto px-4 py-8">
461      <div className="mb-8">
462        <div className="flex items-center justify-between mb-4">
463          <div className="flex items-center">
464            <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
465              step >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
466            }`}>
467              1
468            </div>
469            <div className={`h-1 w-12 ${step >= 2 ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
470          </div>
471          <div className="flex items-center">
472            <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
473              step >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
474            }`}>
475              2
476            </div>
477            <div className={`h-1 w-12 ${step >= 3 ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
478          </div>
479          <div className="flex items-center">
480            <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
481              step >= 3 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
482            }`}>
483              3
484            </div>
485          </div>
486        </div>
487        <div className="flex justify-between text-sm">
488          <span className={step >= 1 ? 'text-blue-500 font-medium' : ''}>Personal data</span>
489          <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Address</span>
490          <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Payment</span>
491        </div>
492      </div>
493
494      <form className="bg-white shadow-md rounded-lg p-6">
495        {renderStep()}
496      </form>
497    </div>
498  );
499}

Main features of this approach:

  • A single component manages the entire process
  • State contains all form data and the current step
  • Switching between steps is done by changing state
  • Validation is performed before moving to the next step

2. Per-step component approach

An alternative approach is to create separate components for each step, which improves code organization and reusability:

1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6// Typy dla danych formularza
7interface UserFormData {
8  firstName: string;
9  lastName: string;
10  email: string;
11}
12
13interface AddressFormData {
14  street: string;
15  city: string;
16  zipCode: string;
17  country: string;
18}
19
20interface PaymentFormData {
21  cardNumber: string;
22  expiryDate: string;
23  cvv: string;
24}
25
26export type FormData = {
27  user: UserFormData;
28  address: AddressFormData;
29  payment: PaymentFormData;
30}
31
32// Component for step 1: User data
33interface UserFormProps {
34  data: UserFormData;
35  onSubmit: (data: UserFormData) => void;
36}
37
38function UserForm({ data, onSubmit }: UserFormProps) {
39  const [formData, setFormData] = useState(data);
40  const [errors, setErrors] = useState<Record<string, string>>({});
41  
42  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
43    const { name, value } = e.target;
44    setFormData({ ...formData, [name]: value });
45    
46    // Clearing errors
47    if (errors[name]) {
48      setErrors({ ...errors, [name]: '' });
49    }
50  }
51  
52  function validate(): boolean {
53    const newErrors: Record<string, string> = {};
54    
55    if (!formData.firstName.trim()) {
56      newErrors.firstName = 'Name is required';
57    }
58    
59    if (!formData.lastName.trim()) {
60      newErrors.lastName = 'Last name is required';
61    }
62    
63    if (!formData.email.trim()) {
64      newErrors.email = 'Email is required';
65    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i.test(formData.email)) {
66      newErrors.email = 'Invalid email address format';
67    }
68    
69    setErrors(newErrors);
70    return Object.keys(newErrors).length === 0;
71  }
72  
73  function handleSubmit(e: React.FormEvent) {
74    e.preventDefault();
75    
76    if (validate()) {
77      onSubmit(formData);
78    }
79  }
80  
81  return (
82    <form onSubmit={handleSubmit} className="space-y-4">
83      <h2 className="text-xl font-semibold">Personal data</h2>
84      
85      <div>
86        <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
87          First name
88        </label>
89        <input
90          type="text"
91          id="firstName"
92          name="firstName"
93          value={formData.firstName}
94          onChange={handleChange}
95          className={`mt-1 block w-full rounded-md border ${
96            errors.firstName ? 'border-red-500' : 'border-gray-300'
97          } px-3 py-2`}
98        />
99        {errors.firstName && (
100          <p className="mt-1 text-sm text-red-600">{errors.firstName}</p>
101        )}
102      </div>
103      
104      <div>
105        <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
106          Last name
107        </label>
108        <input
109          type="text"
110          id="lastName"
111          name="lastName"
112          value={formData.lastName}
113          onChange={handleChange}
114          className={`mt-1 block w-full rounded-md border ${
115            errors.lastName ? 'border-red-500' : 'border-gray-300'
116          } px-3 py-2`}
117        />
118        {errors.lastName && (
119          <p className="mt-1 text-sm text-red-600">{errors.lastName}</p>
120        )}
121      </div>
122      
123      <div>
124        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
125          Email
126        </label>
127        <input
128          type="email"
129          id="email"
130          name="email"
131          value={formData.email}
132          onChange={handleChange}
133          className={`mt-1 block w-full rounded-md border ${
134            errors.email ? 'border-red-500' : 'border-gray-300'
135          } px-3 py-2`}
136        />
137        {errors.email && (
138          <p className="mt-1 text-sm text-red-600">{errors.email}</p>
139        )}
140      </div>
141      
142      <div className="flex justify-end">
143        <button
144          type="submit"
145          className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
146        >
147          Next
148        </button>
149      </div>
150    </form>
151  );
152}
153
154// Komponent dla kroku 2: Shipping address
155interface AddressFormProps {
156  data: AddressFormData;
157  onSubmit: (data: AddressFormData) => void;
158  onBack: () => void;
159}
160
161function AddressForm({ data, onSubmit, onBack }: AddressFormProps) {
162  const [formData, setFormData] = useState(data);
163  const [errors, setErrors] = useState<Record<string, string>>({});
164  
165  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
166    const { name, value } = e.target;
167    setFormData({ ...formData, [name]: value });
168    
169    if (errors[name]) {
170      setErrors({ ...errors, [name]: '' });
171    }
172  }
173  
174  function validate(): boolean {
175    const newErrors: Record<string, string> = {};
176    
177    if (!formData.street.trim()) {
178      newErrors.street = 'Street is required';
179    }
180    
181    if (!formData.city.trim()) {
182      newErrors.city = 'City is required';
183    }
184    
185    if (!formData.zipCode.trim()) {
186      newErrors.zipCode = 'Postal code is required';
187    }
188    
189    if (!formData.country.trim()) {
190      newErrors.country = 'Country is required';
191    }
192    
193    setErrors(newErrors);
194    return Object.keys(newErrors).length === 0;
195  }
196  
197  function handleSubmit(e: React.FormEvent) {
198    e.preventDefault();
199    
200    if (validate()) {
201      onSubmit(formData);
202    }
203  }
204  
205  return (
206    <form onSubmit={handleSubmit} className="space-y-4">
207      <h2 className="text-xl font-semibold">Shipping address</h2>
208      
209      <div>
210        <label htmlFor="street" className="block text-sm font-medium text-gray-700">
211          Street and number
212        </label>
213        <input
214          type="text"
215          id="street"
216          name="street"
217          value={formData.street}
218          onChange={handleChange}
219          className={`mt-1 block w-full rounded-md border ${
220            errors.street ? 'border-red-500' : 'border-gray-300'
221          } px-3 py-2`}
222        />
223        {errors.street && (
224          <p className="mt-1 text-sm text-red-600">{errors.street}</p>
225        )}
226      </div>
227      
228      <div>
229        <label htmlFor="city" className="block text-sm font-medium text-gray-700">
230          City
231        </label>
232        <input
233          type="text"
234          id="city"
235          name="city"
236          value={formData.city}
237          onChange={handleChange}
238          className={`mt-1 block w-full rounded-md border ${
239            errors.city ? 'border-red-500' : 'border-gray-300'
240          } px-3 py-2`}
241        />
242        {errors.city && (
243          <p className="mt-1 text-sm text-red-600">{errors.city}</p>
244        )}
245      </div>
246      
247      <div>
248        <label htmlFor="zipCode" className="block text-sm font-medium text-gray-700">
249          Postal code
250        </label>
251        <input
252          type="text"
253          id="zipCode"
254          name="zipCode"
255          value={formData.zipCode}
256          onChange={handleChange}
257          className={`mt-1 block w-full rounded-md border ${
258            errors.zipCode ? 'border-red-500' : 'border-gray-300'
259          } px-3 py-2`}
260        />
261        {errors.zipCode && (
262          <p className="mt-1 text-sm text-red-600">{errors.zipCode}</p>
263        )}
264      </div>
265      
266      <div>
267        <label htmlFor="country" className="block text-sm font-medium text-gray-700">
268          Country
269        </label>
270        <input
271          type="text"
272          id="country"
273          name="country"
274          value={formData.country}
275          onChange={handleChange}
276          className={`mt-1 block w-full rounded-md border ${
277            errors.country ? 'border-red-500' : 'border-gray-300'
278          } px-3 py-2`}
279        />
280        {errors.country && (
281          <p className="mt-1 text-sm text-red-600">{errors.country}</p>
282        )}
283      </div>
284      
285      <div className="flex justify-between">
286        <button
287          type="button"
288          onClick={onBack}
289          className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
290        >
291          Back
292        </button>
293        <button
294          type="submit"
295          className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
296        >
297          Next
298        </button>
299      </div>
300    </form>
301  );
302}
303
304// Component for step 3: Payment
305interface PaymentFormProps {
306  data: PaymentFormData;
307  onSubmit: (data: PaymentFormData) => void;
308  onBack: () => void;
309}
310
311function PaymentForm({ data, onSubmit, onBack }: PaymentFormProps) {
312  const [formData, setFormData] = useState(data);
313  const [errors, setErrors] = useState<Record<string, string>>({});
314  
315  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
316    const { name, value } = e.target;
317    setFormData({ ...formData, [name]: value });
318    
319    if (errors[name]) {
320      setErrors({ ...errors, [name]: '' });
321    }
322  }
323  
324  function validate(): boolean {
325    const newErrors: Record<string, string> = {};
326    
327    if (!formData.cardNumber.trim()) {
328      newErrors.cardNumber = 'Card number is required';
329    } else if (!/^d{16}$/.test(formData.cardNumber.replace(/s/g, ''))) {
330      newErrors.cardNumber = 'Invalid card number';
331    }
332
333    if (!formData.expiryDate.trim()) {
334      newErrors.expiryDate = 'Expiry date is required';
335    }
336
337    if (!formData.cvv.trim()) {
338      newErrors.cvv = 'CVV code is required';
339    } else if (!/^d{3,4}$/.test(formData.cvv)) {
340      newErrors.cvv = 'Invalid CVV code';
341    }
342    
343    setErrors(newErrors);
344    return Object.keys(newErrors).length === 0;
345  }
346  
347  function handleSubmit(e: React.FormEvent) {
348    e.preventDefault();
349    
350    if (validate()) {
351      onSubmit(formData);
352    }
353  }
354  
355  return (
356    <form onSubmit={handleSubmit} className="space-y-4">
357      <h2 className="text-xl font-semibold">Payment details</h2>
358      
359      <div>
360        <label htmlFor="cardNumber" className="block text-sm font-medium text-gray-700">
361          Card number
362        </label>
363        <input
364          type="text"
365          id="cardNumber"
366          name="cardNumber"
367          value={formData.cardNumber}
368          onChange={handleChange}
369          className={`mt-1 block w-full rounded-md border ${
370            errors.cardNumber ? 'border-red-500' : 'border-gray-300'
371          } px-3 py-2`}
372        />
373        {errors.cardNumber && (
374          <p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
375        )}
376      </div>
377      
378      <div className="grid grid-cols-2 gap-4">
379        <div>
380          <label htmlFor="expiryDate" className="block text-sm font-medium text-gray-700">
381            Expiry date
382          </label>
383          <input
384            type="text"
385            id="expiryDate"
386            name="expiryDate"
387            value={formData.expiryDate}
388            onChange={handleChange}
389            className={`mt-1 block w-full rounded-md border ${
390              errors.expiryDate ? 'border-red-500' : 'border-gray-300'
391            } px-3 py-2`}
392          />
393          {errors.expiryDate && (
394            <p className="mt-1 text-sm text-red-600">{errors.expiryDate}</p>
395          )}
396        </div>
397        
398        <div>
399          <label htmlFor="cvv" className="block text-sm font-medium text-gray-700">
400            CVV
401          </label>
402          <input
403            type="text"
404            id="cvv"
405            name="cvv"
406            value={formData.cvv}
407            onChange={handleChange}
408            className={`mt-1 block w-full rounded-md border ${
409              errors.cvv ? 'border-red-500' : 'border-gray-300'
410            } px-3 py-2`}
411          />
412          {errors.cvv && (
413            <p className="mt-1 text-sm text-red-600">{errors.cvv}</p>
414          )}
415        </div>
416      </div>
417      
418      <div className="flex justify-between">
419        <button
420          type="button"
421          onClick={onBack}
422          className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
423        >
424          Back
425        </button>
426        <button
427          type="submit"
428          className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
429        >
430          Place order
431        </button>
432      </div>
433    </form>
434  );
435}
436
437// Confirmation component
438interface ConfirmationProps {
439  data: FormData;
440  onEdit: (step: number) => void;
441}
442
443function Confirmation({ data, onEdit }: ConfirmationProps) {
444  const router = useRouter();
445  
446  async function handleConfirm() {
447    try {
448      // Simulate API submission
449      console.log('Submitting order:', data);
450
451      // Example API call (in a real application)
452      // await fetch('/api/checkout', {
453      //   method: 'POST',
454      //   headers: { 'Content-Type': 'application/json' },
455      //   body: JSON.stringify(data)
456      // });
457
458      // Redirect to success page
459      // router.push('/checkout/success');
460
461      // In this example we just alert success
462      alert('Order has been placed successfully!');
463    } catch (error) {
464      console.error('Error placing order:', error);
465      alert('An error occurred while placing the order. Try again.');
466    }
467  }
468
469  return (
470    <div className="space-y-6">
471      <h2 className="text-xl font-semibold">Order confirmation</h2>
472      
473      <div className="border border-gray-200 rounded-lg p-4 space-y-4">
474        <div>
475          <h3 className="font-medium text-gray-700 mb-2">Personal data</h3>
476          <div className="bg-gray-50 p-3 rounded">
477            <p>{data.user.firstName} {data.user.lastName}</p>
478            <p>{data.user.email}</p>
479          </div>
480          <button
481            type="button"
482            onClick={() => onEdit(1)}
483            className="text-sm text-blue-500 mt-1"
484          >
485            Edit
486          </button>
487        </div>
488        
489        <div>
490          <h3 className="font-medium text-gray-700 mb-2">Shipping address</h3>
491          <div className="bg-gray-50 p-3 rounded">
492            <p>{data.address.street}</p>
493            <p>{data.address.zipCode} {data.address.city}</p>
494            <p>{data.address.country}</p>
495          </div>
496          <button
497            type="button"
498            onClick={() => onEdit(2)}
499            className="text-sm text-blue-500 mt-1"
500          >
501            Edit
502          </button>
503        </div>
504        
505        <div>
506          <h3 className="font-medium text-gray-700 mb-2">Payment method</h3>
507          <div className="bg-gray-50 p-3 rounded">
508            <p>Card: **** **** **** {data.payment.cardNumber.slice(-4)}</p>
509            <p>Valid until: {data.payment.expiryDate}</p>
510          </div>
511          <button
512            type="button"
513            onClick={() => onEdit(3)}
514            className="text-sm text-blue-500 mt-1"
515          >
516            Edit
517          </button>
518        </div>
519      </div>
520      
521      <div className="flex justify-center">
522        <button
523          type="button"
524          onClick={handleConfirm}
525          className="px-6 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
526        >
527          Confirm and place order
528        </button>
529      </div>
530    </div>
531  );
532}
533
534// Main multi-step form component
535export default function MultiStepCheckout() {
536  const [step, setStep] = useState(1);
537  const [formData, setFormData] = useState<FormData>({
538    user: {
539      firstName: '',
540      lastName: '',
541      email: ''
542    },
543    address: {
544      street: '',
545      city: '',
546      zipCode: '',
547      country: ''
548    },
549    payment: {
550      cardNumber: '',
551      expiryDate: '',
552      cvv: ''
553    }
554  });
555  
556  function handleUserSubmit(userData: UserFormData) {
557    setFormData({ ...formData, user: userData });
558    setStep(2);
559  }
560  
561  function handleAddressSubmit(addressData: AddressFormData) {
562    setFormData({ ...formData, address: addressData });
563    setStep(3);
564  }
565  
566  function handlePaymentSubmit(paymentData: PaymentFormData) {
567    setFormData({ ...formData, payment: paymentData });
568    setStep(4);
569  }
570  
571  function renderProgressBar() {
572    return (
573      <div className="mb-8">
574        <div className="flex items-center justify-between mb-4">
575          {[1, 2, 3, 4].map((num) => (
576            <div key={num} className="flex items-center">
577              <div className={`w-8 h-8 rounded-full flex items-center justify-center ${
578                step >= num ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'
579              }`}>
580                {num}
581              </div>
582              {num < 4 && (
583                <div className={`h-1 w-12 ${step > num ? 'bg-blue-500' : 'bg-gray-200'}`}></div>
584              )}
585            </div>
586          ))}
587        </div>
588        <div className="flex justify-between text-sm">
589          <span className={step >= 1 ? 'text-blue-500 font-medium' : ''}>Personal data</span>
590          <span className={step >= 2 ? 'text-blue-500 font-medium' : ''}>Address</span>
591          <span className={step >= 3 ? 'text-blue-500 font-medium' : ''}>Payment</span>
592          <span className={step >= 4 ? 'text-blue-500 font-medium' : ''}>Confirmation</span>
593        </div>
594      </div>
595    );
596  }
597  
598  function renderStep() {
599    switch (step) {
600      case 1:
601        return <UserForm data={formData.user} onSubmit={handleUserSubmit} />;
602      case 2:
603        return (
604          <AddressForm
605            data={formData.address}
606            onSubmit={handleAddressSubmit}
607            onBack={() => setStep(1)}
608          />
609        );
610      case 3:
611        return (
612          <PaymentForm
613            data={formData.payment}
614            onSubmit={handlePaymentSubmit}
615            onBack={() => setStep(2)}
616          />
617        );
618      case 4:
619        return <Confirmation data={formData} onEdit={setStep} />;
620      default:
621        return null;
622    }
623  }
624  
625  return (
626    <div className="max-w-2xl mx-auto px-4 py-8">
627      {renderProgressBar()}
628      
629      <div className="bg-white shadow-md rounded-lg p-6">
630        {renderStep()}
631      </div>
632    </div>
633  );
634}

Advantages of this approach:

  • Better code organization
  • Each step is a separate, independent component
  • Easier validation for each step
  • Components can be reused in other places
  • Easier testing of individual stages

3. Multi-step forms with Server Actions in Next.js

For Next.js applications we can use Server Actions to handle form data:

1// app/checkout/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { submitUserData, submitAddressData, submitPaymentData, completeOrder } from './actions';
6
7// Similar types and form components as in the previous example
8//
9export default function CheckoutPage() {
10  const [step, setStep] = useState(1);
11  const [userId, setUserId] = useState<string | null>(null);
12  const [formData, setFormData] = useState({
13    user: { /* user data */ },
14    address: { /* address */ },
15    payment: { /* payment */ }
16  });
17
18  async function handleUserSubmit(userData: UserFormData) {
19    try {
20      // Save user data using Server Action
21      const result = await submitUserData(userData);
22
23      if (result.success) {
24        setUserId(result.userId);
25        setFormData({ ...formData, user: userData });
26        setStep(2);
27      } else {
28        // Error handling
29        alert(result.error || 'An error occurred. Try again.');
30      }
31    } catch (error) {
32      console.error('Error submitting user data:', error);
33      alert('An unexpected error occurred. Try again.');
34    }
35  }
36
37  async function handleAddressSubmit(addressData: AddressFormData) {
38    if (!userId) return;
39
40    try {
41      const result = await submitAddressData(userId, addressData);
42
43      if (result.success) {
44        setFormData({ ...formData, address: addressData });
45        setStep(3);
46      } else {
47        alert(result.error || 'An error occurred. Try again.');
48      }
49    } catch (error) {
50      console.error('Error submitting address data:', error);
51      alert('An unexpected error occurred. Try again.');
52    }
53  }
54
55  // Similar functions for the remaining steps
56  //
57  // Render form components for each step, similar to before
58  //
59}

Here's a sample Server Actions implementation:

1// app/checkout/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6// Validation schemas for the forms
7const UserSchema = z.object({
8  firstName: z.string().min(1, { message: 'Name is required' }),
9  lastName: z.string().min(1, { message: 'Last name is required' }),
10  email: z.string().email({ message: 'Invalid email address' })
11});
12
13const AddressSchema = z.object({
14  street: z.string().min(1, { message: 'Street is required' }),
15  city: z.string().min(1, { message: 'City is required' }),
16  zipCode: z.string().min(1, { message: 'Postal code is required' }),
17  country: z.string().min(1, { message: 'Country is required' })
18});
19
20// Server response types
21type ServerResponse<T> = 
22  | { success: true; data: T }
23  | { success: false; error: string };
24
25export async function submitUserData(formData: unknown): Promise<ServerResponse<{ userId: string }>> {
26  try {
27    // Validate data
28    const validatedData = UserSchema.safeParse(formData);
29    
30    if (!validatedData.success) {
31      return {
32        success: false,
33        error: validatedData.error.errors[0]?.message || 'Invalid data'
34      };
35    }
36
37    // In a real application: save data to the database
38    // const user = await db.user.create({ data: validatedData.data });
39
40    // Simulating user creation
41    console.log('User data saved:', validatedData.data);
42
43    return {
44      success: true,
45      data: { userId: 'user_123' }
46    };
47  } catch (error) {
48    console.error('Server error when submitting user data:', error);
49    return {
50      success: false,
51      error: 'An error occurred while processing data'
52    };
53  }
54}
55
56export async function submitAddressData(
57  userId: string, 
58  formData: unknown
59): Promise<ServerResponse<{ addressId: string }>> {
60  try {
61    // Validate data
62    const validatedData = AddressSchema.safeParse(formData);
63    
64    if (!validatedData.success) {
65      return {
66        success: false,
67        error: validatedData.error.errors[0]?.message || 'Invalid address data'
68      };
69    }
70
71    // In a real application: save the address to the database
72    // const address = await db.address.create({
73    //   data: { ...validatedData.data, userId }
74    // });
75
76    // Simulating address saving
77    console.log('Address saved for user', userId, ':', validatedData.data);
78
79    return {
80      success: true,
81      data: { addressId: 'addr_456' }
82    };
83  } catch (error) {
84    console.error('Server error when submitting address data:', error);
85    return {
86      success: false,
87      error: 'An error occurred while processing the address'
88    };
89  }
90}
91
92// Similar functions for payment and order finalization
93//

Advantages of this approach:

  • Data validation and processing on the server
  • Ability to incrementally save data to the database
  • Better protection for sensitive data (e.g., payment details)
  • Support for progressive enhancement

State management in multi-step forms

For more complex forms, it's worth considering libraries for form state management:

State management with Formik or React Hook Form

1// Example with React Hook Form
2import { useForm, FormProvider, useFormContext } from 'react-hook-form';
3import { zodResolver } from '@hookform/resolvers/zod';
4import { z } from 'zod';
5
6// Validation schema
7const CheckoutSchema = z.object({
8  user: z.object({
9    firstName: z.string().min(1, 'Name is required'),
10    lastName: z.string().min(1, 'Last name is required'),
11    email: z.string().email('Invalid email address')
12  }),
13  address: z.object({
14    street: z.string().min(1, 'Street is required'),
15    city: z.string().min(1, 'City is required'),
16    zipCode: z.string().min(1, 'Postal code is required'),
17    country: z.string().min(1, 'Country is required')
18  }),
19  payment: z.object({
20    cardNumber: z.string().refine(val => /^d{16}$/.test(val.replace(/s/g, '')), {
21      message: 'Invalid card number'
22    }),
23    expiryDate: z.string().min(1, 'Expiry date is required'),
24    cvv: z.string().refine(val => /^d{3,4}$/.test(val), {
25      message: 'Invalid CVV code'
26    })
27  })
28});
29
30type CheckoutData = z.infer<typeof CheckoutSchema>;
31
32function UserFormStep() {
33  const { register, formState: { errors } } = useFormContext<CheckoutData>();
34  
35  return (
36    <div className="space-y-4">
37      <h2 className="text-xl font-semibold">Personal data</h2>
38      
39      <div>
40        <label htmlFor="firstName" className="block text-sm font-medium">First name</label>
41        <input
42          id="firstName"
43          {...register('user.firstName')}
44          className={`mt-1 block w-full rounded-md border ${
45            errors.user?.firstName ? 'border-red-500' : 'border-gray-300'
46          } px-3 py-2`}
47        />
48        {errors.user?.firstName && (
49          <p className="mt-1 text-sm text-red-600">{errors.user.firstName.message}</p>
50        )}
51      </div>
52      
53      {/* Remaining fields... */}
54    </div>
55  );
56}
57
58// Similarly define the remaining form steps
59//
60export default function CheckoutForm() {
61  const [step, setStep] = useState(1);
62  
63  const methods = useForm<CheckoutData>({
64    resolver: zodResolver(CheckoutSchema),
65    mode: 'onBlur',
66    defaultValues: {
67      user: { firstName: '', lastName: '', email: '' },
68      address: { street: '', city: '', zipCode: '', country: '' },
69      payment: { cardNumber: '', expiryDate: '', cvv: '' }
70    }
71  });
72  
73  function onSubmit(data: CheckoutData) {
74    console.log('Submitting data:', data);
75    // Logic for submitting data to the API
76  }
77  
78  const nextStep = () => {
79    if (step < 3) {
80      setStep(step + 1);
81    } else {
82      methods.handleSubmit(onSubmit)();
83    }
84  };
85  
86  const prevStep = () => setStep(step - 1);
87  
88  return (
89    <FormProvider {...methods}>
90      <form onSubmit={methods.handleSubmit(onSubmit)}>
91        {/* Progress bar */}
92
93        {/* Render the appropriate step */}
94        {step === 1 && <UserFormStep />}
95        {step === 2 && <AddressFormStep />}
96        {step === 3 && <PaymentFormStep />}
97
98        {/* Navigation buttons */}
99        <div className="mt-6 flex justify-between">
100          {step > 1 && (
101            <button
102              type="button"
103              onClick={prevStep}
104              className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md"
105            >
106              Back
107            </button>
108          )}
109          
110          <button
111            type="button"
112            onClick={nextStep}
113            className={`px-4 py-2 ${
114              step === 3 ? 'bg-green-500 hover:bg-green-600' : 'bg-blue-500 hover:bg-blue-600'
115            } text-white rounded-md`}
116          >
117            {step === 3 ? 'Place order' : 'Next'}
118          </button>
119        </div>
120      </form>
121    </FormProvider>
122  );
123}

State management using React Context

For very complex forms, it can be beneficial to use the React Context API:

1// context/CheckoutContext.tsx
2import { createContext, useContext, useReducer, ReactNode } from 'react';
3
4// Form data types
5//
6// Form state
7interface CheckoutState {
8  step: number;
9  user: UserFormData;
10  address: AddressFormData;
11  payment: PaymentFormData;
12  errors: Record<string, string>;
13}
14
15// Action types
16type CheckoutAction =
17  | { type: 'SET_STEP'; payload: number }
18  | { type: 'UPDATE_USER'; payload: Partial<UserFormData> }
19  | { type: 'UPDATE_ADDRESS'; payload: Partial<AddressFormData> }
20  | { type: 'UPDATE_PAYMENT'; payload: Partial<PaymentFormData> }
21  | { type: 'SET_ERRORS'; payload: Record<string, string> }
22  | { type: 'CLEAR_ERRORS' };
23
24// Initial state
25const initialState: CheckoutState = {
26  step: 1,
27  user: { firstName: '', lastName: '', email: '' },
28  address: { street: '', city: '', zipCode: '', country: '' },
29  payment: { cardNumber: '', expiryDate: '', cvv: '' },
30  errors: {}
31};
32
33// Reducer
34function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
35  switch (action.type) {
36    case 'SET_STEP':
37      return { ...state, step: action.payload };
38    case 'UPDATE_USER':
39      return { ...state, user: { ...state.user, ...action.payload } };
40    case 'UPDATE_ADDRESS':
41      return { ...state, address: { ...state.address, ...action.payload } };
42    case 'UPDATE_PAYMENT':
43      return { ...state, payment: { ...state.payment, ...action.payload } };
44    case 'SET_ERRORS':
45      return { ...state, errors: action.payload };
46    case 'CLEAR_ERRORS':
47      return { ...state, errors: {} };
48    default:
49      return state;
50  }
51}
52
53// Context type
54interface CheckoutContextType {
55  state: CheckoutState;
56  nextStep: () => void;
57  prevStep: () => void;
58  updateUser: (data: Partial<UserFormData>) => void;
59  updateAddress: (data: Partial<AddressFormData>) => void;
60  updatePayment: (data: Partial<PaymentFormData>) => void;
61  validateStep: (step: number) => boolean;
62  submitOrder: () => Promise<void>;
63}
64
65// Context
66const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined);
67
68// Provider
69export function CheckoutProvider({ children }: { children: ReactNode }) {
70  const [state, dispatch] = useReducer(checkoutReducer, initialState);
71
72  // Helper functions
73  const nextStep = () => {
74    if (validateStep(state.step)) {
75      dispatch({ type: 'SET_STEP', payload: state.step + 1 });
76    }
77  };
78  
79  const prevStep = () => {
80    dispatch({ type: 'SET_STEP', payload: state.step - 1 });
81  };
82  
83  const updateUser = (data: Partial<UserFormData>) => {
84    dispatch({ type: 'UPDATE_USER', payload: data });
85  };
86  
87  const updateAddress = (data: Partial<AddressFormData>) => {
88    dispatch({ type: 'UPDATE_ADDRESS', payload: data });
89  };
90  
91  const updatePayment = (data: Partial<PaymentFormData>) => {
92    dispatch({ type: 'UPDATE_PAYMENT', payload: data });
93  };
94  
95  // Step validation
96  const validateStep = (step: number): boolean => {
97    // Validation logic...
98    return true; // Simplified for the example
99  };
100
101  // Submitting the order
102  const submitOrder = async () => {
103    if (validateStep(state.step)) {
104      try {
105        // Data submission logic
106        console.log('Submitting data:', state);
107      } catch (error) {
108        console.error('Error submitting order:', error);
109      }
110    }
111  };
112  
113  return (
114    <CheckoutContext.Provider
115      value={{
116        state,
117        nextStep,
118        prevStep,
119        updateUser,
120        updateAddress,
121        updatePayment,
122        validateStep,
123        submitOrder
124      }}
125    >
126      {children}
127    </CheckoutContext.Provider>
128  );
129}
130
131// Hook to use the context
132export function useCheckout() {
133  const context = useContext(CheckoutContext);
134  if (context === undefined) {
135    throw new Error('useCheckout must be used within a CheckoutProvider');
136  }
137  return context;
138}

Advanced techniques for multi-step forms

1. Conditional form paths

Sometimes we want users to go through different form paths depending on their choices:

1// Sample implementation of conditional paths
2function determineNextStep(currentStep: number, formData: FormData): number {
3  if (currentStep === 1) {
4    // If user chose shipping to a different address, add the shipping address step
5    if (formData.shippingDifferent) {
6      return 2; // Shipping address step
7    } else {
8      return 3; // Skip shipping address step, go to payment
9    }
10  }
11
12  if (currentStep === 3) {
13    // If user chose bank transfer payment, skip the card details step
14    if (formData.paymentMethod === 'bankTransfer') {
15      return 5; // Go to summary
16    } else {
17      return 4; // Card details step
18    }
19  }
20
21  // Default transition to next step
22  return currentStep + 1;
23}
24
25// Usage in a component
26function handleNext() {
27  const isValid = validateCurrentStep();
28
29  if (isValid) {
30    const nextStep = determineNextStep(step, formData);
31    setStep(nextStep);
32  }
33}

2. Saving and restoring form state

For long forms, it's worth allowing progress to be saved:

1'use client';
2
3import { useState, useEffect } from 'react';
4
5// Save form state
6function saveFormState(data: FormData) {
7  localStorage.setItem('checkout_form', JSON.stringify(data));
8  localStorage.setItem('checkout_timestamp', Date.now().toString());
9}
10
11// Restore form state
12function getSavedFormState(): FormData | null {
13  const savedData = localStorage.getItem('checkout_form');
14  const timestamp = localStorage.getItem('checkout_timestamp');
15  
16  if (!savedData || !timestamp) {
17    return null;
18  }
19  
20  // Check if saved data is not too old (e.g., 24 hours)
21  const savedTime = parseInt(timestamp, 10);
22  const now = Date.now();
23  const oneDayMs = 24 * 60 * 60 * 1000;
24
25  if (now - savedTime > oneDayMs) {
26    // Data is too old, remove it
27    localStorage.removeItem('checkout_form');
28    localStorage.removeItem('checkout_timestamp');
29    return null;
30  }
31  
32  try {
33    return JSON.parse(savedData) as FormData;
34  } catch (e) {
35    return null;
36  }
37}
38
39export default function CheckoutForm() {
40  const [formData, setFormData] = useState<FormData>({
41    // Default form state
42  });
43
44  const [step, setStep] = useState(1);
45  const [hasRestoredState, setHasRestoredState] = useState(false);
46
47  // Attempt to restore form state on first render
48  useEffect(() => {
49    if (!hasRestoredState) {
50      const savedState = getSavedFormState();
51
52      if (savedState) {
53        setFormData(savedState);
54        setHasRestoredState(true);
55
56        // Optionally, ask the user if they want to continue from where they left off
57        const wishToContinue = window.confirm(
58          'An incomplete form from a previous session was found. Do you want to continue from where you left off?'
59        );
60
61        if (wishToContinue) {
62          // We can determine which step the user ended on
63          // e.g., based on which data is already filled in
64          if (savedState.payment.cardNumber) {
65            setStep(3);
66          } else if (savedState.address.street) {
67            setStep(2);
68          } else {
69            setStep(1);
70          }
71        } else {
72          // User doesn't want to continue, clear saved data
73          localStorage.removeItem('checkout_form');
74          localStorage.removeItem('checkout_timestamp');
75          setFormData({
76            // Reset to default state
77          });
78        }
79      }
80
81      setHasRestoredState(true);
82    }
83  }, [hasRestoredState]);
84
85  // Save form state on changes
86  useEffect(() => {
87    if (hasRestoredState) {
88      saveFormState(formData);
89    }
90  }, [formData, hasRestoredState]);
91
92  // Rest of the component...
93}

3. Dynamically adding and removing form fields

In some multi-step forms, users may need to dynamically add or remove elements:

1'use client';
2
3import { useState } from 'react';
4
5interface Item {
6  id: string;
7  name: string;
8  quantity: number;
9}
10
11function OrderItemsStep() {
12  const [items, setItems] = useState<Item[]>([
13    { id: '1', name: '', quantity: 1 }
14  ]);
15  
16  const addItem = () => {
17    const newId = (parseInt(items[items.length - 1].id) + 1).toString();
18    setItems([...items, { id: newId, name: '', quantity: 1 }]);
19  };
20  
21  const removeItem = (id: string) => {
22    // Don't remove the last item
23    if (items.length <= 1) return;
24    
25    setItems(items.filter(item => item.id !== id));
26  };
27  
28  const updateItem = (id: string, field: keyof Item, value: string | number) => {
29    setItems(items.map(item => 
30      item.id === id ? { ...item, [field]: value } : item
31    ));
32  };
33  
34  return (
35    <div className="space-y-4">
36      <h2 className="text-xl font-semibold">Order items</h2>
37      
38      {items.map(item => (
39        <div key={item.id} className="flex items-center space-x-4 p-4 border rounded-md">
40          <div className="flex-grow">
41            <label htmlFor={`item-name-${item.id}`} className="block text-sm font-medium">
42              Product name
43            </label>
44            <input
45              id={`item-name-${item.id}`}
46              type="text"
47              value={item.name}
48              onChange={(e) => updateItem(item.id, 'name', e.target.value)}
49              className="mt-1 block w-full rounded-md border-gray-300 px-3 py-2"
50            />
51          </div>
52          
53          <div className="w-24">
54            <label htmlFor={`item-qty-${item.id}`} className="block text-sm font-medium">
55              Quantity
56            </label>
57            <input
58              id={`item-qty-${item.id}`}
59              type="number"
60              min="1"
61              value={item.quantity}
62              onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value))}
63              className="mt-1 block w-full rounded-md border-gray-300 px-3 py-2"
64            />
65          </div>
66          
67          <button
68            type="button"
69            onClick={() => removeItem(item.id)}
70            className="mt-6 px-2 py-2 bg-red-100 text-red-600 rounded-md hover:bg-red-200"
71            aria-label="Remove item"
72          >
73            &times;
74          </button>
75        </div>
76      ))}
77      
78      <button
79        type="button"
80        onClick={addItem}
81        className="w-full mt-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
82      >
83        + Add more products
84      </button>
85    </div>
86  );
87}

Good practices for multi-step forms

  1. Show progress - indicate to the user where they are in the process (progress bar, step numbering)

  2. Preserve data - users should not lose entered data when the page is refreshed or accidentally closed

  3. Enable navigation between steps - allow users to return to previous steps and edit data

  4. Validate each step - check data correctness before moving to the next step

  5. Provide clear error messages - inform the user exactly what went wrong

  6. Allow saving and returning later - especially for long forms

  7. Adapt steps to the device - on smaller screens consider even smaller steps

  8. Provide a summary before final submission - give users an overview of all entered data

  9. Handle browser back/forward - adapt the application to work with the browser's back/forward button

  10. Test various scenarios - make sure the form works correctly both for standard filling and in scenarios such as:

    • Going back to previous steps
    • Refreshing the page
    • Attempting to submit incomplete data
    • Losing internet connection

Drizzle ORM - a lightweight alternative to Prisma

In Quantum Metropolis of the year 2150, where every processor cycle matters, developers often look for tools that are both powerful and lightweight. Drizzle ORM is a modern alternative to Prisma that offers type safety while staying close to pure SQL.

Drizzle vs Prisma - comparison

Both Drizzle and Prisma are type-safe ORMs for TypeScript, but they differ in philosophy and approach:

| Aspect | Drizzle ORM | Prisma | |--------|-------------|--------| | Philosophy | SQL-like, closer to the metal | Abstraction-first, friendly API | | Size | ~20KB | ~600KB+ | | Migrations | SQL or TypeScript | Custom DSL language | | Queries | SQL-like | Custom API | | Performance | Very fast | Good | | Learning curve | Gentle for SQL users | Gentle for beginners | | Type safety | Full | Full | | Edge Runtime | Native support | Requires Data Proxy |

Why Drizzle is lighter and closer to SQL

Drizzle is a "thin TypeScript layer" over SQL, which means:

  1. Minimal abstraction - queries look like SQL, so they are easy to optimize
  2. Small bundle size - Drizzle code is compact
  3. Zero runtime overhead - all types are removed during compilation
  4. Full control - you can use raw SQL when you need to
1// Drizzle - looks like SQL
2const result = await db
3  .select()
4  .from(users)
5  .where(eq(users.email, 'user@example.com'))
6  .limit(1);
7
8// Prisma - more abstract
9const result = await prisma.user.findUnique({
10  where: { email: 'user@example.com' }
11});

Type-safe queries with Drizzle

Drizzle offers full TypeScript support, catching errors while you are writing code:

Schema definition

1// db/schema.ts
2import { pgTable, serial, text, varchar, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
3import { relations } from 'drizzle-orm';
4
5// Users table
6export const users = pgTable('users', {
7  id: serial('id').primaryKey(),
8  name: varchar('name', { length: 255 }).notNull(),
9  email: varchar('email', { length: 255 }).notNull().unique(),
10  password: text('password').notNull(),
11  createdAt: timestamp('created_at').defaultNow().notNull(),
12  updatedAt: timestamp('updated_at').defaultNow().notNull()
13});
14
15// Posts table
16export const posts = pgTable('posts', {
17  id: serial('id').primaryKey(),
18  title: varchar('title', { length: 255 }).notNull(),
19  content: text('content').notNull(),
20  published: boolean('published').default(false).notNull(),
21  authorId: integer('author_id')
22    .notNull()
23    .references(() => users.id, { onDelete: 'cascade' }),
24  createdAt: timestamp('created_at').defaultNow().notNull(),
25  updatedAt: timestamp('updated_at').defaultNow().notNull()
26});
27
28// Comments table
29export const comments = pgTable('comments', {
30  id: serial('id').primaryKey(),
31  content: text('content').notNull(),
32  postId: integer('post_id')
33    .notNull()
34    .references(() => posts.id, { onDelete: 'cascade' }),
35  authorId: integer('author_id')
36    .notNull()
37    .references(() => users.id, { onDelete: 'cascade' }),
38  createdAt: timestamp('created_at').defaultNow().notNull()
39});
40
41// Relations between tables
42export const usersRelations = relations(users, ({ many }) => ({
43  posts: many(posts),
44  comments: many(comments)
45}));
46
47export const postsRelations = relations(posts, ({ one, many }) => ({
48  author: one(users, {
49    fields: [posts.authorId],
50    references: [users.id]
51  }),
52  comments: many(comments)
53}));
54
55export const commentsRelations = relations(comments, ({ one }) => ({
56  post: one(posts, {
57    fields: [comments.postId],
58    references: [posts.id]
59  }),
60  author: one(users, {
61    fields: [comments.authorId],
62    references: [users.id]
63  })
64}));
65
66// TypeScript types generated automatically
67export type User = typeof users.$inferSelect;
68export type NewUser = typeof users.$inferInsert;
69
70export type Post = typeof posts.$inferSelect;
71export type NewPost = typeof posts.$inferInsert;
72
73export type Comment = typeof comments.$inferSelect;
74export type NewComment = typeof comments.$inferInsert;

Connection configuration

1// db/index.ts
2import { drizzle } from 'drizzle-orm/postgres-js';
3import postgres from 'postgres';
4import * as schema from './schema';
5
6// PostgreSQL client
7const connectionString = process.env.DATABASE_URL!;
8const client = postgres(connectionString);
9
10// Drizzle instance
11export const db = drizzle(client, { schema });
12
13// For Edge Runtime (Vercel, Cloudflare Workers)
14// import { drizzle } from 'drizzle-orm/vercel-postgres';
15// import { sql } from '@vercel/postgres';
16// export const db = drizzle(sql, { schema });

Type-safe queries

1// app/actions/users.ts
2'use server';
3
4import { db } from '@/db';
5import { users, posts } from '@/db/schema';
6import { eq, and, or, like, desc, count } from 'drizzle-orm';
7
8// SELECT - fetching data
9export async function getUser(id: number) {
10  const [user] = await db
11    .select()
12    .from(users)
13    .where(eq(users.id, id))
14    .limit(1);
15
16  return user;
17}
18
19// SELECT with multiple conditions
20export async function searchUsers(query: string) {
21  return await db
22    .select({
23      id: users.id,
24      name: users.name,
25      email: users.email
26    })
27    .from(users)
28    .where(
29      or(
30        like(users.name, `%${query}%`),
31        like(users.email, `%${query}%`)
32      )
33    )
34    .limit(10);
35}
36
37// INSERT - creating a user
38export async function createUser(data: {
39  name: string;
40  email: string;
41  password: string;
42}) {
43  const [newUser] = await db
44    .insert(users)
45    .values(data)
46    .returning(); // PostgreSQL specific
47
48  return newUser;
49}
50
51// UPDATE - updating a user
52export async function updateUser(
53  id: number,
54  data: Partial<{ name: string; email: string }>
55) {
56  const [updated] = await db
57    .update(users)
58    .set({
59      ...data,
60      updatedAt: new Date()
61    })
62    .where(eq(users.id, id))
63    .returning();
64
65  return updated;
66}
67
68// DELETE - deleting a user
69export async function deleteUser(id: number) {
70  await db
71    .delete(users)
72    .where(eq(users.id, id));
73}
74
75// JOIN - fetching a user with their posts
76export async function getUserWithPosts(userId: number) {
77  return await db.query.users.findFirst({
78    where: eq(users.id, userId),
79    with: {
80      posts: {
81        orderBy: [desc(posts.createdAt)],
82        limit: 10
83      }
84    }
85  });
86}
87
88// Aggregations
89export async function getPostsCount(userId: number) {
90  const [result] = await db
91    .select({ count: count() })
92    .from(posts)
93    .where(eq(posts.authorId, userId));
94
95  return result.count;
96}
97
98// Transactions
99export async function createUserWithPost(
100  userData: { name: string; email: string; password: string },
101  postData: { title: string; content: string }
102) {
103  return await db.transaction(async (tx) => {
104    // Create user
105    const [user] = await tx
106      .insert(users)
107      .values(userData)
108      .returning();
109
110    // Create a post for this user
111    const [post] = await tx
112      .insert(posts)
113      .values({
114        ...postData,
115        authorId: user.id,
116        published: false
117      })
118      .returning();
119
120    return { user, post };
121  });
122}

Advanced queries

Relational queries (Relational Queries API)

1// The simplest way to do JOINs
2export async function getPostsWithAuthors() {
3  return await db.query.posts.findMany({
4    with: {
5      author: true,
6      comments: {
7        with: {
8          author: true
9        }
10      }
11    },
12    orderBy: [desc(posts.createdAt)],
13    limit: 20
14  });
15}
16
17// Partial select with relations
18export async function getPostsPartial() {
19  return await db.query.posts.findMany({
20    columns: {
21      id: true,
22      title: true,
23      createdAt: true
24    },
25    with: {
26      author: {
27        columns: {
28          id: true,
29          name: true
30        }
31      }
32    }
33  });
34}
35
36// Filtering within relations
37export async function getPublishedPostsWithComments() {
38  return await db.query.posts.findMany({
39    where: eq(posts.published, true),
40    with: {
41      comments: {
42        where: (comments, { gte }) =>
43          gte(comments.createdAt, new Date('2024-01-01')),
44        limit: 5,
45        orderBy: [desc(comments.createdAt)]
46      }
47    }
48  });
49}

Raw SQL when you need it

1import { sql } from 'drizzle-orm';
2
3// Raw SQL for advanced queries
4export async function complexAnalytics() {
5  const result = await db.execute(sql`
6    SELECT
7      u.id,
8      u.name,
9      COUNT(DISTINCT p.id) as posts_count,
10      COUNT(DISTINCT c.id) as comments_count,
11      MAX(p.created_at) as last_post_date
12    FROM users u
13    LEFT JOIN posts p ON p.author_id = u.id
14    LEFT JOIN comments c ON c.author_id = u.id
15    WHERE u.created_at > NOW() - INTERVAL '30 days'
16    GROUP BY u.id, u.name
17    ORDER BY posts_count DESC
18    LIMIT 10
19  `);
20
21  return result.rows;
22}
23
24// Combining type-safe queries with raw SQL
25export async function hybridQuery(minPosts: number) {
26  return await db
27    .select({
28      userId: users.id,
29      userName: users.name,
30      postsCount: sql<number>`COUNT(${posts.id})`
31    })
32    .from(users)
33    .leftJoin(posts, eq(posts.authorId, users.id))
34    .groupBy(users.id)
35    .having(sql`COUNT(${posts.id}) > ${minPosts}`);
36}

Migrations in Drizzle

Drizzle offers two ways to handle migrations:

1. Automatic SQL generation

1// drizzle.config.ts
2import type { Config } from 'drizzle-kit';
3
4export default {
5  schema: './db/schema.ts',
6  out: './drizzle',
7  driver: 'pg',
8  dbCredentials: {
9    connectionString: process.env.DATABASE_URL!
10  }
11} satisfies Config;
1# Generate migrations
2npx drizzle-kit generate:pg
3
4# Run migrations
5npx drizzle-kit push:pg
6
7# Or using a custom script
8# db/migrate.ts
9import { drizzle } from 'drizzle-orm/postgres-js';
10import { migrate } from 'drizzle-orm/postgres-js/migrator';
11import postgres from 'postgres';
12
13const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
14const db = drizzle(sql);
15
16await migrate(db, { migrationsFolder: './drizzle' });
17await sql.end();

2. Manual SQL migrations

1-- drizzle/0001_initial.sql
2CREATE TABLE IF NOT EXISTS "users" (
3  "id" serial PRIMARY KEY NOT NULL,
4  "name" varchar(255) NOT NULL,
5  "email" varchar(255) NOT NULL,
6  "password" text NOT NULL,
7  "created_at" timestamp DEFAULT now() NOT NULL,
8  "updated_at" timestamp DEFAULT now() NOT NULL,
9  CONSTRAINT "users_email_unique" UNIQUE("email")
10);
11
12CREATE TABLE IF NOT EXISTS "posts" (
13  "id" serial PRIMARY KEY NOT NULL,
14  "title" varchar(255) NOT NULL,
15  "content" text NOT NULL,
16  "published" boolean DEFAULT false NOT NULL,
17  "author_id" integer NOT NULL,
18  "created_at" timestamp DEFAULT now() NOT NULL,
19  "updated_at" timestamp DEFAULT now() NOT NULL,
20  CONSTRAINT "posts_author_id_users_id_fk"
21    FOREIGN KEY ("author_id") REFERENCES "users"("id")
22    ON DELETE cascade
23);

Integracja z Next.js App Router

1// app/users/[id]/page.tsx
2import { db } from '@/db';
3import { users, posts } from '@/db/schema';
4import { eq, desc } from 'drizzle-orm';
5
6export default async function UserPage({
7  params
8}: {
9  params: Promise<{ id: string }>
10}) {
11  const userId = parseInt((await params).id);
12
13  // Query executed in a Server Component
14  const user = await db.query.users.findFirst({
15    where: eq(users.id, userId),
16    with: {
17      posts: {
18        orderBy: [desc(posts.createdAt)],
19        limit: 5
20      }
21    }
22  });
23
24  if (!user) {
25    return <div>User not found</div>;
26  }
27
28  return (
29    <div className="p-8">
30      <h1 className="text-3xl font-bold mb-4">{user.name}</h1>
31      <p className="text-gray-600 mb-8">{user.email}</p>
32
33      <h2 className="text-2xl font-semibold mb-4">Posts</h2>
34      <div className="space-y-4">
35        {user.posts.map(post => (
36          <article key={post.id} className="border p-4 rounded-lg">
37            <h3 className="text-xl font-bold">{post.title}</h3>
38            <p className="text-gray-600 mt-2">{post.content}</p>
39            <time className="text-sm text-gray-400 mt-2 block">
40              {post.createdAt.toLocaleDateString()}
41            </time>
42          </article>
43        ))}
44      </div>
45    </div>
46  );
47}
48
49// app/actions/posts.ts
50'use server';
51
52import { db } from '@/db';
53import { posts } from '@/db/schema';
54import { revalidatePath } from 'next/cache';
55
56export async function createPost(formData: FormData) {
57  const title = formData.get('title') as string;
58  const content = formData.get('content') as string;
59  const authorId = parseInt(formData.get('authorId') as string);
60
61  const [newPost] = await db
62    .insert(posts)
63    .values({
64      title,
65      content,
66      authorId,
67      published: false
68    })
69    .returning();
70
71  revalidatePath(`/users/${authorId}`);
72
73  return { success: true, post: newPost };
74}

Drizzle vs Prisma - when to choose which?

Choose Drizzle when:

  • ✅ You know SQL well and want full control
  • ✅ You care about a small bundle size
  • ✅ You are building edge applications
  • ✅ You need maximum performance
  • ✅ You prefer minimalism and simplicity

Choose Prisma when:

  • ✅ You are a beginner or intermediate developer
  • ✅ You need excellent documentation and ecosystem
  • ✅ You want abstraction and don't want to worry about SQL
  • ✅ You use Prisma Studio for data management
  • ✅ Your team is larger and needs consistent conventions

Practical Application

In Quantum Metropolis, where applications have to be both powerful and lightweight, Drizzle offers an excellent alternative. Its closeness to SQL means experienced developers can easily optimize queries, while full TypeScript support provides type safety at every step.

1// Real-world example: Dashboard with analytics
2export async function getDashboardStats(userId: number) {
3  // Everything type-safe, SQL-like, fast
4  const stats = await db
5    .select({
6      totalPosts: count(posts.id),
7      publishedPosts: sql<number>`
8        COUNT(CASE WHEN ${posts.published} = true THEN 1 END)
9      `,
10      draftPosts: sql<number>`
11        COUNT(CASE WHEN ${posts.published} = false THEN 1 END)
12      `,
13      totalComments: count(comments.id)
14    })
15    .from(users)
16    .leftJoin(posts, eq(posts.authorId, users.id))
17    .leftJoin(comments, eq(comments.postId, posts.id))
18    .where(eq(users.id, userId))
19    .groupBy(users.id);
20
21  return stats[0];
22}

Summary

Multi-step forms are a powerful tool for collecting complex data from users in a friendly and organized way. There are many strategies for implementing them, from simple component-state-based approaches, through modular approaches with separate components for each step, to advanced solutions that leverage React context and form management libraries.

In React and Next.js, we have flexible options for implementing such forms, with additional benefits offered by Server Actions in Next.js for progressive enhancement and data security.

The key to success is choosing an approach appropriate to the form's complexity and the application's needs, while providing users with a smooth and frictionless experience.

Go to CodeWorlds