import { z } from 'zod'
export const postSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters')
.max(100, 'Title must be at most 100 characters'),
body: z.string().min(50, 'Body must be at least 50 characters'),
status: z.enum(['draft', 'published'], {
errorMap: () => ({ message: 'Invalid status' }),
}),
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed').optional(),
})
export type PostFormData = z.infer<typeof postSchema>
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { postSchema, PostFormData } from '@/schemas/postSchema'
interface PostFormProps {
onSubmit: (data: PostFormData) => Promise<void>
defaultValues?: Partial<PostFormData>
}
export function PostForm({ onSubmit, defaultValues }: PostFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<PostFormData>({
resolver: zodResolver(postSchema),
defaultValues,
})
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Title
</label>
<input {...register('title')} id="title" className="w-full px-3 py-2 border rounded" />
{errors.title && <p className="text-red-600 text-sm mt-1">{errors.title.message}</p>}
</div>
<div>
<label htmlFor="body" className="block text-sm font-medium mb-1">
Body
</label>
<textarea {...register('body')} id="body" rows={10} className="w-full px-3 py-2 border rounded" />
{errors.body && <p className="text-red-600 text-sm mt-1">{errors.body.message}</p>}
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium mb-1">
Status
</label>
<select {...register('status')} id="status" className="w-full px-3 py-2 border rounded">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
{errors.status && <p className="text-red-600 text-sm mt-1">{errors.status.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Post'}
</button>
</form>
)
}
Combining React Hook Form with Zod provides type-safe form validation with a schema-first approach. Zod schemas define the shape and rules for form data, and the zodResolver bridges them to React Hook Form. TypeScript infers form types from schemas automatically, ensuring consistency between validation and TypeScript types. I define schemas once and use them for both client-side validation and API request typing. Custom error messages integrate cleanly with React Hook Form's error display. This pattern eliminates duplicate validation logic between frontend and backend when I generate Zod schemas from Rails model validations. The combination delivers excellent DX with minimal boilerplate.