React Hook Form with Zod
Deep Knowledge: Use mcp__documentation__fetch_docs with technology: react-hook-form for comprehensive documentation on React Hook Form integration with Zod and shadcn/ui.
Form Setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
const (data: LoginFormData) => {
await login(data);
};
return (
<form
{/* fields */}
</form>
);
}
With shadcn/ui Form Components
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
function LoginForm() {
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
return (
<Form {...form}>
<form className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Loading...' : 'Login'}
</Button>
</form>
</Form>
);
}
Complex Validation Schema
const userSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters'),
email: z.string().email('Invalid email'),
role: z.enum(['admin', 'user', 'manager'], {
errorMap: () => ({ message: 'Select a valid role' }),
}),
department: z.string().optional(),
startDate: z.date({
required_error: 'Start date is required',
}),
salary: z.number()
.min(0, 'Salary must be positive')
.optional(),
});
// Conditional validation
const formSchema = z.object({
hasAddress: z.boolean(),
address: z.string().optional(),
}).refine(
(data) => !data.hasAddress || data.address,
{ message: 'Address is required', path: ['address'] }
);
Select Field
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
Date Picker Field
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Start Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline" className="w-full justify-start text-left">
<CalendarIcon className="mr-2 h-4 w-4" />
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={field.value}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
Form with API Mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UserForm({ userId }: { userId?: string }) {
const queryClient = useQueryClient();
const isEditing = !!userId;
const mutation = useMutation({
mutationFn: isEditing ? usersApi.update : usersApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success(isEditing ? 'User updated' : 'User created');
},
onError: (error) => {
toast.error(error.message);
},
});
const UserFormData) => {
if (isEditing) {
mutation.mutate({ id: userId, ...data });
} else {
mutation.mutate(data);
}
};
return (
<Form {...form}>
<form
{/* fields */}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</Button>
</form>
</Form>
);
}
Key Patterns
| Pattern |
Description |
zodResolver |
Zod validation integration |
form.formState |
Form state (isSubmitting, errors, etc.) |
form.reset() |
Reset form to defaults |
form.setValue() |
Programmatic value setting |
form.watch() |
Watch field changes |
form.trigger() |
Trigger validation |
When NOT to Use This Skill
- React 19 Server Actions forms - Use
react-19 skill for useActionState patterns
- Basic form handling - Use
react-forms skill for general form patterns
- Non-shadcn/ui components - Adapt patterns or use
react-forms skill
- Non-Zod validation - Use
react-forms skill for other validation libraries
Anti-Patterns
| Anti-Pattern |
Problem |
Solution |
| Not using zodResolver |
Manual validation, boilerplate |
Always use zodResolver with Zod |
| Missing FormField wrapper |
No error handling, poor UX |
Wrap inputs in FormField |
| Not checking formState.isSubmitting |
Button not disabled during submit |
Use isSubmitting to disable button |
| Using register without FormField |
No shadcn/ui integration |
Use FormField with Controller |
| Not calling reset() after success |
Form not cleared |
Call form.reset() on successful submit |
| Complex validation in component |
Hard to test, maintain |
Define schema with Zod |
| Not handling mutation errors |
Silent failures |
Use onError in mutation |
Quick Troubleshooting
| Issue |
Likely Cause |
Fix |
| Validation not working |
Missing zodResolver |
Add resolver: zodResolver(schema) to useForm |
| Errors not displaying |
Missing FormMessage |
Add in FormField |
| Form not resetting |
Not calling reset() |
Call form.reset() after successful submit |
| Submit button not disabling |
Not using isSubmitting |
Use form.formState.isSubmitting |
| Custom component not working |
Not using Controller |
Wrap with Controller in FormField |
| Default values not showing |
Not in defaultValues |
Add to defaultValues in useForm |
| Type errors |
Schema not matching FormData type |
Ensure z.infer matches interface |
Reference Documentation
- Form Patterns
- Validation Schemas