Forms are everywhere in web applications. React Hook Form provides a performant, flexible way to build forms with minimal re-renders. Combined with Next.js Server Actions and Zod validation, it creates a great developer experience for forms of any complexity.
Why React Hook Form?
React Hook Form solves common form problems:
| Problem | React Hook Form solution |
|---|---|
| Re-renders on every keystroke | Uses refs, not state |
| Boilerplate for each field | register() handles inputs |
| Complex validation logic | Resolver pattern with Zod |
| Dynamic fields | useFieldArray hook |
| Form state management | Built-in formState object |
Installation
Install React Hook Form and optional validation resolver:
npm install react-hook-form @hookform/resolvers zod
Basic setup
Create a form with useForm:
'use client';
import { useForm } from 'react-hook-form';
interface FormData {
name: string;
email: string;
message: string;
}
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>();
async function onSubmit(data: FormData) {
console.log(data);
// Submit to server
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name', { required: 'Name is required' })} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <p>{errors.email.message}</p>}
<textarea {...register('message')} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
</form>
);
}
Zod validation
Use Zod for type-safe schema validation:
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type FormData = z.infer<typeof schema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
// ...
}
The resolver validates all fields and returns typed errors.
With Server Actions
Combine client validation with Server Actions:
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { submitContact } from './actions';
import { contactSchema, type ContactFormData } from './schema';
export function ContactForm() {
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
async function onSubmit(data: ContactFormData) {
const result = await submitContact(data);
if (result.error) {
// Handle server errors
if (result.fieldErrors) {
Object.entries(result.fieldErrors).forEach(([field, messages]) => {
setError(field as keyof ContactFormData, {
message: messages[0],
});
});
} else {
setError('root', { message: result.error });
}
return;
}
reset();
// Show success message
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* form fields */}
</form>
);
}
Server action:
'use server';
import { contactSchema } from './schema';
export async function submitContact(data: ContactFormData) {
const result = contactSchema.safeParse(data);
if (!result.success) {
return {
error: 'Validation failed',
fieldErrors: result.error.flatten().fieldErrors,
};
}
await db.insert(contacts).values(result.data);
revalidatePath('/contact');
return { success: true };
}
Form state
Access form state for UI feedback:
const {
formState: {
errors, // Field errors
isSubmitting, // Form is being submitted
isValid, // All fields valid
isDirty, // Any field changed
dirtyFields, // Which fields changed
touchedFields, // Which fields were focused
},
} = useForm<FormData>();
Validation modes
Control when validation runs:
| Mode | Validation timing |
|---|---|
onSubmit |
Only on form submit (default) |
onBlur |
When field loses focus |
onChange |
On every change (performance cost) |
onTouched |
On blur for touched fields, then onChange |
all |
On blur and onChange |
useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched', // Show errors after first blur
});
Default values
Pre-populate forms:
useForm<FormData>({
defaultValues: {
name: '',
email: user?.email ?? '',
plan: 'free',
},
});
For async defaults:
useForm<FormData>({
defaultValues: async () => {
const user = await fetchUser();
return {
name: user.name,
email: user.email,
};
},
});
Controller for custom components
Use Controller for controlled components like select libraries:
import { Controller } from 'react-hook-form';
import { Select } from '@/components/ui';
<Controller
name="category"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
options={categoryOptions}
error={fieldState.error?.message}
/>
)}
/>
Dynamic fields with useFieldArray
Handle arrays of fields:
import { useFieldArray } from 'react-hook-form';
function DynamicForm() {
const { control, register } = useForm({
defaultValues: {
items: [{ name: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>
Add Item
</button>
</form>
);
}
Watch values
React to field changes:
const { watch } = useForm<FormData>();
// Watch single field
const email = watch('email');
// Watch multiple fields
const [name, email] = watch(['name', 'email']);
// Watch all fields
const allValues = watch();
For performance, use watch sparingly—it triggers re-renders.
Reset and clear
Reset form to defaults or clear values:
const { reset, resetField } = useForm<FormData>();
// Reset entire form
reset();
// Reset with new values
reset({ name: 'New Name', email: 'new@email.com' });
// Reset single field
resetField('email');
Error handling patterns
Display errors consistently:
function FormField({
name,
label,
register,
errors,
...props
}: FormFieldProps) {
const error = errors[name];
return (
<div className="space-y-1">
<label htmlFor={name}>{label}</label>
<input
id={name}
{...register(name)}
{...props}
className={error ? 'border-red-500' : 'border-gray-300'}
/>
{error && (
<p className="text-sm text-red-500">{error.message}</p>
)}
</div>
);
}
File uploads
Handle file inputs:
const { register, handleSubmit } = useForm<{ file: FileList }>();
async function onSubmit(data: { file: FileList }) {
const formData = new FormData();
formData.append('file', data.file[0]);
await uploadFile(formData);
}
<input type="file" {...register('file')} accept="image/*" />
Performance tips
| Tip | Implementation |
|---|---|
| Avoid watching all fields | Watch specific fields only |
| Use mode: 'onTouched' | Balance UX and performance |
| Memoize handlers | useCallback for callbacks |
| Split large forms | Multiple smaller forms or steps |
| DevTools for debugging | @hookform/devtools package |
DevTools
Debug form state visually:
npm install -D @hookform/devtools
import { DevTool } from '@hookform/devtools';
function Form() {
const { control } = useForm();
return (
<>
<form>{/* fields */}</form>
<DevTool control={control} />
</>
);
}
Summary
React Hook Form minimizes re-renders by using refs instead of state. Use Zod for type-safe validation with the resolver pattern. Integrate with Server Actions by calling them from handleSubmit. Use Controller for custom components and useFieldArray for dynamic fields. Set validation mode based on UX needs. The result is performant, maintainable form code.
