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
- Performance: Minimal re-renders (uncontrolled inputs)
- TypeScript: Excellent type inference
- Zod Integration: Use same schemas as backend validation
- Small Bundle: ~9KB (vs Formik ~15KB)
- 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)