Form Patterns
Build robust, type-safe forms with React Hook Form and Zod validation.
Overview#
Forms are the primary way users interact with your application. This pattern provides:
- Type-safe form handling with React Hook Form
- Schema-based validation with Zod
- Server Action integration for Next.js
- Dynamic and multi-step form support
- File upload handling
Prerequisites#
npm install react-hook-form @hookform/resolvers zodBasic Form#
A simple contact form with validation.
1// components/forms/ContactForm.tsx
2'use client'
3
4import { useForm } from 'react-hook-form'
5import { zodResolver } from '@hookform/resolvers/zod'
6import { z } from 'zod'
7
8const formSchema = z.object({
9 name: z.string().min(1, 'Name is required'),
10 email: z.string().email('Invalid email'),
11 message: z.string().min(10, 'Message must be at least 10 characters')
12})
13
14type FormData = z.infer<typeof formSchema>
15
16export function ContactForm() {
17 const {
18 register,
19 handleSubmit,
20 formState: { errors, isSubmitting },
21 reset
22 } = useForm<FormData>({
23 resolver: zodResolver(formSchema)
24 })
25
26 async function onSubmit(data: FormData) {
27 await fetch('/api/contact', {
28 method: 'POST',
29 body: JSON.stringify(data)
30 })
31 reset()
32 }
33
34 return (
35 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
36 <div>
37 <label htmlFor="name">Name</label>
38 <input
39 id="name"
40 {...register('name')}
41 className="w-full rounded border p-2"
42 />
43 {errors.name && (
44 <p className="text-sm text-red-500">{errors.name.message}</p>
45 )}
46 </div>
47
48 <div>
49 <label htmlFor="email">Email</label>
50 <input
51 id="email"
52 type="email"
53 {...register('email')}
54 className="w-full rounded border p-2"
55 />
56 {errors.email && (
57 <p className="text-sm text-red-500">{errors.email.message}</p>
58 )}
59 </div>
60
61 <div>
62 <label htmlFor="message">Message</label>
63 <textarea
64 id="message"
65 {...register('message')}
66 rows={4}
67 className="w-full rounded border p-2"
68 />
69 {errors.message && (
70 <p className="text-sm text-red-500">{errors.message.message}</p>
71 )}
72 </div>
73
74 <button
75 type="submit"
76 disabled={isSubmitting}
77 className="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
78 >
79 {isSubmitting ? 'Sending...' : 'Send'}
80 </button>
81 </form>
82 )
83}Server Action Form#
Integrate forms with Next.js Server Actions for server-side processing.
1// app/actions.ts
2'use server'
3
4import { z } from 'zod'
5import { revalidatePath } from 'next/cache'
6
7const schema = z.object({
8 title: z.string().min(1),
9 content: z.string().min(1)
10})
11
12export async function createPost(formData: FormData) {
13 const parsed = schema.safeParse({
14 title: formData.get('title'),
15 content: formData.get('content')
16 })
17
18 if (!parsed.success) {
19 return { error: parsed.error.flatten().fieldErrors }
20 }
21
22 await prisma.post.create({ data: parsed.data })
23 revalidatePath('/posts')
24
25 return { success: true }
26}
27
28// components/CreatePostForm.tsx
29'use client'
30
31import { useFormState, useFormStatus } from 'react-dom'
32import { createPost } from '@/app/actions'
33
34function SubmitButton() {
35 const { pending } = useFormStatus()
36
37 return (
38 <button type="submit" disabled={pending}>
39 {pending ? 'Creating...' : 'Create Post'}
40 </button>
41 )
42}
43
44export function CreatePostForm() {
45 const [state, action] = useFormState(createPost, null)
46
47 return (
48 <form action={action} className="space-y-4">
49 <div>
50 <input name="title" placeholder="Title" />
51 {state?.error?.title && (
52 <p className="text-red-500">{state.error.title}</p>
53 )}
54 </div>
55
56 <div>
57 <textarea name="content" placeholder="Content" />
58 {state?.error?.content && (
59 <p className="text-red-500">{state.error.content}</p>
60 )}
61 </div>
62
63 <SubmitButton />
64 </form>
65 )
66}Dynamic Form Fields#
Add and remove form fields dynamically using useFieldArray.
1// components/forms/DynamicForm.tsx
2'use client'
3
4import { useForm, useFieldArray } from 'react-hook-form'
5import { zodResolver } from '@hookform/resolvers/zod'
6import { z } from 'zod'
7
8const schema = z.object({
9 items: z.array(z.object({
10 name: z.string().min(1),
11 quantity: z.number().min(1)
12 })).min(1, 'Add at least one item')
13})
14
15type FormData = z.infer<typeof schema>
16
17export function DynamicForm() {
18 const { register, control, handleSubmit, formState: { errors } } = useForm<FormData>({
19 resolver: zodResolver(schema),
20 defaultValues: { items: [{ name: '', quantity: 1 }] }
21 })
22
23 const { fields, append, remove } = useFieldArray({
24 control,
25 name: 'items'
26 })
27
28 return (
29 <form onSubmit={handleSubmit(console.log)} className="space-y-4">
30 {fields.map((field, index) => (
31 <div key={field.id} className="flex gap-2">
32 <input
33 {...register(`items.${index}.name`)}
34 placeholder="Item name"
35 />
36 <input
37 type="number"
38 {...register(`items.${index}.quantity`, { valueAsNumber: true })}
39 placeholder="Qty"
40 />
41 <button type="button" onClick={() => remove(index)}>
42 Remove
43 </button>
44 </div>
45 ))}
46
47 {errors.items && (
48 <p className="text-red-500">{errors.items.message}</p>
49 )}
50
51 <div className="flex gap-2">
52 <button
53 type="button"
54 onClick={() => append({ name: '', quantity: 1 })}
55 >
56 Add Item
57 </button>
58 <button type="submit">Submit</button>
59 </div>
60 </form>
61 )
62}Multi-Step Form#
Build wizard-style forms with step validation.
1// components/forms/MultiStepForm.tsx
2'use client'
3
4import { useState } from 'react'
5import { useForm, FormProvider } from 'react-hook-form'
6import { zodResolver } from '@hookform/resolvers/zod'
7import { z } from 'zod'
8
9const stepSchemas = {
10 personal: z.object({
11 name: z.string().min(1),
12 email: z.string().email()
13 }),
14 address: z.object({
15 street: z.string().min(1),
16 city: z.string().min(1),
17 zip: z.string().min(5)
18 }),
19 payment: z.object({
20 cardNumber: z.string().min(16),
21 expiry: z.string().min(5)
22 })
23}
24
25const fullSchema = z.object({
26 ...stepSchemas.personal.shape,
27 ...stepSchemas.address.shape,
28 ...stepSchemas.payment.shape
29})
30
31type FormData = z.infer<typeof fullSchema>
32
33const steps = ['personal', 'address', 'payment'] as const
34
35export function MultiStepForm() {
36 const [step, setStep] = useState(0)
37 const currentStep = steps[step]
38
39 const methods = useForm<FormData>({
40 resolver: zodResolver(fullSchema),
41 mode: 'onChange'
42 })
43
44 async function nextStep() {
45 const fields = Object.keys(stepSchemas[currentStep].shape) as (keyof FormData)[]
46 const isValid = await methods.trigger(fields)
47
48 if (isValid) {
49 setStep(s => Math.min(s + 1, steps.length - 1))
50 }
51 }
52
53 function prevStep() {
54 setStep(s => Math.max(s - 1, 0))
55 }
56
57 return (
58 <FormProvider {...methods}>
59 <form onSubmit={methods.handleSubmit(console.log)}>
60 {/* Progress indicator */}
61 <div className="mb-8 flex justify-between">
62 {steps.map((s, i) => (
63 <div
64 key={s}
65 className={`h-2 flex-1 ${i <= step ? 'bg-blue-500' : 'bg-gray-200'}`}
66 />
67 ))}
68 </div>
69
70 {/* Step content */}
71 {currentStep === 'personal' && <PersonalStep />}
72 {currentStep === 'address' && <AddressStep />}
73 {currentStep === 'payment' && <PaymentStep />}
74
75 {/* Navigation */}
76 <div className="mt-4 flex justify-between">
77 <button
78 type="button"
79 onClick={prevStep}
80 disabled={step === 0}
81 >
82 Back
83 </button>
84
85 {step < steps.length - 1 ? (
86 <button type="button" onClick={nextStep}>
87 Next
88 </button>
89 ) : (
90 <button type="submit">Submit</button>
91 )}
92 </div>
93 </form>
94 </FormProvider>
95 )
96}Best Practices#
- Always use Zod schemas - Define validation rules declaratively for type safety
- Extract reusable field components - Create wrapper components for common input types
- Handle loading states - Show feedback during form submission
- Validate on blur - Use
mode: 'onBlur'for better UX on long forms - Reset forms after success - Clear form state after successful submission
Related Patterns#
- Validation - Advanced input validation
- Server Actions - Server-side form handling
- File Upload - File upload forms