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.
Splitting a long form into smaller steps brings many benefits:
There are several main approaches to implementing multi-step forms:
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:
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:
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:
For more complex forms, it's worth considering libraries for form state management:
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}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}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}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}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 ×
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}Show progress - indicate to the user where they are in the process (progress bar, step numbering)
Preserve data - users should not lose entered data when the page is refreshed or accidentally closed
Enable navigation between steps - allow users to return to previous steps and edit data
Validate each step - check data correctness before moving to the next step
Provide clear error messages - inform the user exactly what went wrong
Allow saving and returning later - especially for long forms
Adapt steps to the device - on smaller screens consider even smaller steps
Provide a summary before final submission - give users an overview of all entered data
Handle browser back/forward - adapt the application to work with the browser's back/forward button
Test various scenarios - make sure the form works correctly both for standard filling and in scenarios such as:
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.
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 |
Drizzle is a "thin TypeScript layer" over SQL, which means:
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});Drizzle offers full TypeScript support, catching errors while you are writing code:
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;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 });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}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}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}Drizzle offers two ways to handle migrations:
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();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);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}Choose Drizzle when:
Choose Prisma when:
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}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.