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 zod

Basic 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#

  1. Always use Zod schemas - Define validation rules declaratively for type safety
  2. Extract reusable field components - Create wrapper components for common input types
  3. Handle loading states - Show feedback during form submission
  4. Validate on blur - Use mode: 'onBlur' for better UX on long forms
  5. Reset forms after success - Clear form state after successful submission