Skip to main content

Form Handling Patterns

This guide explains how to build type-safe forms with validation using React Hook Form and Zod. These patterns ensure data integrity before sending to the API and provide clear feedback to users.

When to Use This Guide

Use this guide when you need to:

  • Build forms for creating and editing entities (products, users, orders)
  • Validate user input before submitting to the API
  • Display field-level error messages to users
  • Handle complex forms with dynamic fields (order items, tags)
  • Show conditional fields based on other form values

Problems This Pattern Solves

ProblemSolution
Invalid data sent to APIZod validates before submission
Type mismatch between form and APIInfer TypeScript types from Zod schema
Form re-renders on every keystrokeReact Hook Form uses uncontrolled inputs
Hard to show validation errorsAutomatic error state per field
Dynamic fields are complex to manageuseFieldArray handles add/remove

Technology Stack

LibraryPurpose
React Hook FormForm state management
ZodSchema validation
@hookform/resolversZod integration
shadcn/ui FormForm UI components

Installation

npm install react-hook-form zod @hookform/resolvers

Form Component Architecture

The form system uses a layered component architecture:

ComponentRole
FormContext provider that wraps the entire form (uses FormProvider from react-hook-form)
FormFieldConnects a field to form state using Controller
FormItemContainer for a single form field (label, input, error)
FormLabelLabel that auto-connects to the field and shows error state
FormControlPasses form field props to the input element
FormMessageDisplays validation error message
FormDescriptionOptional help text for the field

Basic Form Structure

Use Case: Product Create Form

Scenario: User needs to create a new product with code, name, price, and status.

Solution: Define schema with validation rules, use Form components for consistent error display.

Zod Schema Definition

Define validation rules that match your API requirements:

// src/schemas/product.schema.ts
import { z } from 'zod';

export const createProductSchema = z.object({
code: z
.string()
.min(1, 'Code is required')
.max(50, 'Code must be 50 characters or less')
.regex(/^[A-Z0-9-]+$/, 'Code must be uppercase alphanumeric with hyphens'),
name: z
.string()
.min(1, 'Name is required')
.max(200, 'Name must be 200 characters or less'),
price: z
.number()
.min(0, 'Price must be positive')
.max(999999999, 'Price exceeds maximum'),
description: z
.string()
.max(2000, 'Description must be 2000 characters or less')
.optional(),
categoryId: z.string().min(1, 'Category is required'),
status: z.enum(['ACTIVE', 'INACTIVE', 'DRAFT']),
});

export type CreateProductInput = z.infer<typeof createProductSchema>;

// Update schema with optional fields and version
export const updateProductSchema = createProductSchema.partial().extend({
version: z.number().int().positive(),
});

export type UpdateProductInput = z.infer<typeof updateProductSchema>;

Form Component

Connect the schema to React Hook Form using Form components:

// src/components/forms/ProductForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
createProductSchema,
CreateProductInput,
} from '@/schemas/product.schema';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';

interface ProductFormProps {
onSubmit: (data: CreateProductInput) => Promise<void>;
defaultValues?: Partial<CreateProductInput>;
isLoading?: boolean;
}

export function ProductForm({
onSubmit,
defaultValues,
isLoading,
}: ProductFormProps) {
const form = useForm<CreateProductInput>({
resolver: zodResolver(createProductSchema),
defaultValues: {
status: 'DRAFT',
...defaultValues,
},
});

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Code</FormLabel>
<FormControl>
<Input {...field} disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} disabled={isLoading} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isLoading}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="INACTIVE">Inactive</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>

<Button
type="submit"
loading={form.formState.isSubmitting || isLoading}
disabled={form.formState.isSubmitting || isLoading}
>
Save
</Button>
</form>
</Form>
);
}

Reusable Form Components

Use Case: Consistent Form Field Styling

Scenario: All form fields should have consistent label, error display, and required indicator.

Solution: Create a custom wrapper component that uses shadcn/ui Form components.

// src/components/form/CustomFormItem.tsx
'use client';

import * as React from 'react';
import { FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { cn } from '@/lib/utils';

export default function CustomFormItem({
className,
label,
required,
children,
}: {
className?: string;
label: string;
children: React.ReactNode;
required?: boolean;
}) {
return (
<FormItem className={cn('flex w-full flex-col gap-2', className)}>
<FormLabel className="font-semibold">
<span className="text-[hsl(var(--foreground))]">{label}</span>
{required && <span className="text-destructive ml-1">*</span>}
</FormLabel>
<div className="relative flex-col">
{children}
<FormMessage className="mt-2 text-xs font-semibold" />
</div>
</FormItem>
);
}

Using CustomFormItem

<FormField
control={form.control}
name="code"
render={({ field }) => (
<CustomFormItem label="Code" required>
<FormControl>
<Input {...field} />
</FormControl>
</CustomFormItem>
)}
/>

Input Component (Simple, No Error Prop)

Note: The Input component does NOT have an error prop. Error display is handled by FormMessage.

// src/components/ui/input.tsx
import * as React from 'react';
import { cn } from '@/lib/utils';

export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-[hsl(var(--input))] bg-[hsl(var(--background))] px-3 py-2 text-sm ring-offset-[hsl(var(--background))] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[hsl(var(--muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);

Input.displayName = 'Input';

export { Input };

Form Component Implementation

The Form components from shadcn/ui provide context-based error handling:

// src/components/ui/form.tsx
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from './label';

// Form is just FormProvider re-exported
const Form = FormProvider;

// Context type for FormField
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};

const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);

// FormField wraps Controller and provides field context
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};

// useFormField hook retrieves field state from context
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();

const fieldState = getFieldState(fieldContext.name, formState);

if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}

const { id } = itemContext;

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};

// Context type for FormItem
type FormItemContextValue = {
id: string;
};

const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);

// FormItem provides unique ID via context
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();

return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';

// FormLabel shows error state via color
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();

return (
<Label
ref={ref}
className={cn(error && 'text-[hsl(var(--destructive))]', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';

// FormControl passes aria attributes for accessibility
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();

return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';

// FormDescription provides help text
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();

return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-[hsl(var(--muted-foreground))]', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';

// FormMessage automatically displays error from context
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;

if (!body) {
return null;
}

return (
<p
ref={ref}
id={formMessageId}
className={cn(
'text-sm font-medium text-[hsl(var(--destructive))]',
className
)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';

export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

Advanced Form Patterns

Use Case: Order Form with Multiple Items

Scenario: User creates an order with multiple line items. Items can be added or removed.

Problem: Managing array of fields with validation is complex.

Solution: useFieldArray provides add, remove, and update methods with proper validation.

// src/components/forms/OrderForm.tsx
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';

const orderItemSchema = z.object({
productId: z.string().min(1, 'Product is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
price: z.number().min(0),
});

const orderSchema = z.object({
customerId: z.string().min(1, 'Customer is required'),
items: z.array(orderItemSchema).min(1, 'At least one item is required'),
notes: z.string().optional(),
});

type OrderInput = z.infer<typeof orderSchema>;

export function OrderForm({
onSubmit,
}: {
onSubmit: (data: OrderInput) => void;
}) {
const form = useForm<OrderInput>({
resolver: zodResolver(orderSchema),
defaultValues: {
items: [{ productId: '', quantity: 1, price: 0 }],
},
});

const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
});

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel>Customer</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className="space-y-4">
<h3>Order Items</h3>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4 items-end">
<FormField
control={form.control}
name={`items.${index}.productId`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Product ID" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
placeholder="Qty"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="button" variant="destructive" onClick={() => remove(index)}>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="secondary"
onClick={() => append({ productId: '', quantity: 1, price: 0 })}
>
Add Item
</Button>
</div>

<Button type="submit">Create Order</Button>
</form>
</Form>
);
}

Use Case: Payment Form with Conditional Fields

Scenario: Form shows different fields based on payment method selection.

Problem: Need to watch a field value and conditionally render other fields.

Solution: useWatch subscribes to field changes without causing full form re-render.

import { useForm, useWatch } from 'react-hook-form';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';

function PaymentForm() {
const form = useForm({
defaultValues: {
paymentMethod: 'credit_card',
cardNumber: '',
bankAccount: '',
},
});

const paymentMethod = useWatch({
control: form.control,
name: 'paymentMethod',
});

return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="paymentMethod"
render={({ field }) => (
<FormItem>
<FormLabel>Payment Method</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="credit_card">Credit Card</SelectItem>
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>

{paymentMethod === 'credit_card' && (
<FormField
control={form.control}
name="cardNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Card Number</FormLabel>
<FormControl>
<Input {...field} placeholder="Card Number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}

{paymentMethod === 'bank_transfer' && (
<FormField
control={form.control}
name="bankAccount"
render={({ field }) => (
<FormItem>
<FormLabel>Bank Account</FormLabel>
<FormControl>
<Input {...field} placeholder="Bank Account" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
);
}

Form with React Query

Use Case: Create Product with API Integration

Scenario: Submit form data to API and handle success/error states.

Solution: Container component combines form with React Query mutation.

// src/containers/products/CreateProductForm.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useCreateProduct } from '@/hooks/useProducts';
import { ProductForm } from '@/components/forms/ProductForm';
import { CreateProductInput } from '@/schemas/product.schema';
import { toast } from '@/components/ui/toast';

export function CreateProductForm() {
const router = useRouter();
const createProduct = useCreateProduct();

const handleSubmit = async (data: CreateProductInput) => {
try {
await createProduct.mutateAsync(data);
toast.success('Product created successfully');
router.push('/products');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to create product'
);
}
};

return (
<ProductForm onSubmit={handleSubmit} isLoading={createProduct.isPending} />
);
}

Use Case: Edit Product with Pre-populated Data

Scenario: Load existing product data into form for editing.

Problem: Need to fetch data before rendering form with default values.

Solution: Container fetches data, passes to form as defaultValues.

// src/containers/products/EditProductForm.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useProduct, useUpdateProduct } from '@/hooks/useProducts';
import { ProductForm } from '@/components/forms/ProductForm';
import { UpdateProductInput } from '@/schemas/product.schema';
import { toast } from '@/components/ui/toast';

interface EditProductFormProps {
pk: string;
sk: string;
}

export function EditProductForm({ pk, sk }: EditProductFormProps) {
const router = useRouter();
const { data: product, isLoading } = useProduct(pk, sk);
const updateProduct = useUpdateProduct();

const handleSubmit = async (data: UpdateProductInput) => {
try {
await updateProduct.mutateAsync({
pk,
sk,
dto: { ...data, version: product!.version },
});
toast.success('Product updated successfully');
router.push('/products');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update product'
);
}
};

if (isLoading) return <Skeleton />;
if (!product) return <NotFound />;

return (
<ProductForm
defaultValues={product}
onSubmit={handleSubmit}
isLoading={updateProduct.isPending}
/>
);
}

Complex Validation Patterns

Use Case: Date Range Validation

Scenario: End date must be after start date.

Solution: Use Zod refine to validate across multiple fields.

const dateRangeSchema = z
.object({
startDate: z.date(),
endDate: z.date(),
})
.refine((data) => data.endDate >= data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
});

Use Case: Unique Code Validation

Scenario: Product code must not already exist in database.

Problem: Need to call API to check uniqueness.

Solution: Use async refine to validate against API.

const uniqueCodeSchema = z.object({
code: z
.string()
.min(1, 'Code is required')
.refine(
async (code) => {
const exists = await checkCodeExists(code);
return !exists;
},
{ message: 'This code is already in use' }
),
});

// In the form
const form = useForm({
resolver: zodResolver(uniqueCodeSchema),
mode: 'onBlur', // Validate on blur for async validation
});

Best Practices

1. Colocate Schemas with Forms

Why: Keeps validation logic close to the form that uses it.

src/
├── components/forms/
│ └── ProductForm/
│ ├── ProductForm.tsx
│ ├── ProductForm.schema.ts
│ └── index.ts

2. Use Mode Appropriately

Choose validation timing based on form complexity:

// Validate on submit (default) - best for simple forms
useForm({ mode: 'onSubmit' });

// Validate on blur - best for forms with async validation
useForm({ mode: 'onBlur' });

// Validate on change - best for real-time feedback
useForm({ mode: 'onChange' });

3. Extract Common Schemas

Why: Reuse validation rules across multiple forms.

// src/schemas/common.ts
export const requiredString = z.string().min(1, 'This field is required');
export const email = z.string().email('Invalid email address');
export const positiveNumber = z.number().positive('Must be positive');

4. Handle Server Errors

Scenario: Server returns field-level validation errors.

Solution: Use setError to display server errors on specific fields.

function FormWithServerErrors() {
const form = useForm();

const onSubmit = async (data) => {
try {
await api.create(data);
} catch (error) {
if (error.details) {
// Set field-level errors from server
Object.entries(error.details).forEach(([field, messages]) => {
form.setError(field, { message: messages[0] });
});
}
}
};

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>...</form>
</Form>
);
}