Skip to main content

ADR-0021: React Hook Form for Form Handling

Status

Accepted - 2025-01-26


Context

TVL Platform has complex forms (booking creation, property setup, user settings) requiring validation, error handling, and submission.


Decision

React Hook Form + Zod (ADR-0013) for form handling and validation.

Rationale

  1. Performance: Minimal re-renders (uncontrolled inputs)
  2. TypeScript: Excellent type inference
  3. Zod Integration: Use same schemas as backend validation
  4. Small Bundle: ~9KB (vs Formik ~15KB)
  5. DevTools: Inspect form state

Alternatives Considered

Alternative 1: Formik

Rejected - Slower (controlled inputs), larger bundle

Alternative 2: Plain React State

Rejected - No validation, must build everything manually

Alternative 3: Native HTML5 Validation

Rejected - Limited, inconsistent browser support


Installation

pnpm add react-hook-form @hookform/resolvers zod

Usage Examples

1. Basic Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const BookingSchema = z.object({
guestName: z.string().min(1, 'Guest name is required'),
checkIn: z.string().datetime(),
checkOut: z.string().datetime(),
totalCents: z.number().int().positive()
});

type BookingFormData = z.infer<typeof BookingSchema>;

export function BookingForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<BookingFormData>({
resolver: zodResolver(BookingSchema)
});

const onSubmit = async (data: BookingFormData) => {
await fetch('/api/bookings', {
method: 'POST',
body: JSON.stringify(data)
});
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('guestName')} />
{errors.guestName && (
<span className="text-red-500">{errors.guestName.message}</span>
)}

<input type="datetime-local" {...register('checkIn')} />
{errors.checkIn && (
<span className="text-red-500">{errors.checkIn.message}</span>
)}

<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Booking'}
</button>
</form>
);
}

2. With Shadcn UI Components

import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';

export function BookingForm() {
const form = useForm<BookingFormData>({
resolver: zodResolver(BookingSchema)
});

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

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

3. Custom Validation

const BookingSchema = z.object({
checkIn: z.string().datetime(),
checkOut: z.string().datetime()
}).refine(data => new Date(data.checkOut) > new Date(data.checkIn), {
message: 'Check-out must be after check-in',
path: ['checkOut']
});

4. Async Validation

const { register } = useForm({
resolver: zodResolver(BookingSchema),
mode: 'onBlur' // Validate on blur
});

<input
{...register('email', {
validate: async (value) => {
const exists = await checkEmailExists(value);
return exists ? 'Email already registered' : true;
}
})}
/>

5. Dynamic Fields

import { useFieldArray } from 'react-hook-form';

function PropertyForm() {
const { control } = useForm();
const { fields, append, remove } = useFieldArray({
control,
name: 'amenities'
});

return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`amenities.${index}.name`)} />
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '' })}>Add Amenity</button>
</>
);
}

Form State Management

const {
register, // Register inputs
handleSubmit, // Handle form submission
formState, // Form state (errors, isDirty, isValid)
watch, // Watch field values
setValue, // Set field value programmatically
reset, // Reset form to default values
trigger, // Trigger validation manually
control // For controlled components (Controller)
} = useForm();

// Watch specific field
const guestName = watch('guestName');

// Set value programmatically
setValue('guestName', 'John Doe');

// Reset form
reset({ guestName: '', checkIn: '', checkOut: '' });

Error Handling

const { errors } = formState;

// Field-level errors
errors.guestName?.message // "Guest name is required"

// Form-level errors (from server)
setError('root.serverError', {
type: 'manual',
message: 'Booking already exists for these dates'
});

// Display root error
{errors.root?.serverError && (
<div className="text-red-500">{errors.root.serverError.message}</div>
)}

Integration with TanStack Query

import { useMutation } from '@tanstack/react-query';

function BookingForm() {
const form = useForm<BookingFormData>({
resolver: zodResolver(BookingSchema)
});

const mutation = useMutation({
mutationFn: async (data: BookingFormData) => {
const res = await fetch('/api/bookings', {
method: 'POST',
body: JSON.stringify(data)
});
if (!res.ok) throw new Error('Failed to create booking');
return res.json();
},
onSuccess: () => {
form.reset(); // Reset form after success
toast.success('Booking created!');
},
onError: (error) => {
form.setError('root.serverError', {
message: error.message
});
}
});

return (
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
{/* Form fields */}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</Button>
</form>
);
}

Benefits

  • Performance: Minimal re-renders (uncontrolled inputs)
  • Type Safety: Full TypeScript support
  • Validation: Zod schemas reused from backend
  • Developer Experience: Simple API, great DevTools
  • Bundle Size: Small (~9KB)

References