React Form Handling with React Hook Form
Build performant forms with React Hook Form using uncontrolled components and validation.
Overview
React Hook Form brings performance to form handling by using uncontrolled components with ref tracking rather than controlled components that maintain state on every keystroke. This approach dramatically reduces re-renders and enables form state management without performance overhead. The register function connects form inputs to the form state without wrapping inputs in providers or context. It returns onChange, onBlur, name, and ref properties that wire directly to HTML elements. This minimal API surface keeps bundle size small while providing comprehensive form functionality. Validation integrates with popular validation libraries through resolver patterns. Zod validation provides type-safe schemas that automatically infer TypeScript types for form data. This eliminates the manual type annotation step and ensures validation rules and types stay in sync as requirements change. Form state accessed through useForm returns methods for triggering validation, errors, and touched fields. The handleSubmit callback receives only valid form data when validation passes. The shouldUseNativeValidation prop enables browser-native validation when custom validation isn't required, reducing JavaScript bundle impact. Error display should react to the form state rather than local component state. Using Controller component for complex inputs like date pickers or dropdowns wraps controlled components in the uncontrolled pattern. This enables custom inputs to participate in form validation without managing their own form state.
Code Example
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const projectSchema = z.object({
name: z.string()
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name cannot exceed 50 characters'),
description: z.string()
.max(500, 'Description cannot exceed 500 characters')
.optional(),
startDate: z.date({
required_error: 'Start date is required',
}),
endDate: z.date().optional(),
teamMembers: z.array(z.object({
userId: z.string(),
role: z.enum(['admin', 'member', 'viewer']),
})).min(1, 'At least one team member is required'),
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed'),
isPublic: z.boolean().default(false),
}).refine(
(data) => !data.endDate || data.endDate > data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
);
type ProjectFormData = z.infer<typeof projectSchema>;
export function CreateProjectForm({ onSubmit, defaultValues }: {
onSubmit: (data: ProjectFormData) => void;
defaultValues?: Partial<ProjectFormData>;
}) {
const {
register,
handleSubmit,
control,
watch,
setValue,
formState: { errors, isSubmitting, isValid },
} = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: {
isPublic: false,
tags: [],
...defaultValues,
},
mode: 'onBlur',
});
const watchedFields = watch(['name', 'tags']);
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="name">Project Name</label>
<input
id="name"
{...register('name')}
placeholder="My Awesome Project"
/>
{errors.name && (
<span className="error">{errors.name.message}</span>
)}
<span className="char-count">
{watchedFields[0]?.length || 0}/50
</span>
</div>
<div>
<label>Description</label>
<textarea
{...register('description')}
rows={4}
placeholder="What does this project do?"
/>
{errors.description && (
<span className="error">{errors.description.message}</span>
)}
</div>
<div className="date-range">
<div>
<label>Start Date</label>
<Controller
name="startDate"
control={control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
dateFormat="yyyy-MM-dd"
/>
)}
/>
{errors.startDate && (
<span className="error">{errors.startDate.message}</span>
)}
</div>
<div>
<label>End Date (Optional)</label>
<Controller
name="endDate"
control={control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
dateFormat="yyyy-MM-dd"
minDate={watch('startDate')}
/>
)}
/>
{errors.endDate && (
<span className="error">{errors.endDate.message}</span>
)}
</div>
</div>
<div>
<label>
<input type="checkbox" {...register('isPublic')} />
Make project public
</label>
</div>
<div className="form-actions">
<button
type="button"
onClick={() => setValue('name', '')}
>
Reset
</button>
<button
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
);
}More React Rules
React Component Testing with Testing Library
Test behavior over implementation. Use Testing Library queries to interact with components as users would.
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm }...React Server Components Data Fetching Patterns
Master async/await patterns in Server Components for efficient data loading and caching.
import { db } from '@/lib/db';
import { cache } from 'next/cache';
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleto...React Performance Optimization Patterns
Use React.memo, useMemo, and useCallback correctly to prevent unnecessary re-renders.
import React, { useState, useMemo, useCallback, memo, useEffect } from 'react';
// Expensive calculation helper
function calculateExpensiveValue(item...