Skip to content
Drivn logoDrivn

React Hook Form

Build type-safe, validated forms using React Hook Form, Zod, and Drivn components.

Preview

A complete login form with schema validation, error handling, and loading state. Submit with empty fields to see validation in action.

Installation

Install React Hook Form, the Zod resolver, and Zod.

1npm install react-hook-form @hookform/resolvers zod

Usage

Every form follows three steps — define a schema, call useForm, and render your fields.

1import { useForm, type SubmitHandler } from 'react-hook-form'
2import { zodResolver } from '@hookform/resolvers/zod'
3import { z } from 'zod'
4
5// 1. Define your schema
6const schema = z.object({
7 email: z.email('Enter a valid email'),
8 password: z.string().min(8, 'At least 8 characters'),
9})
10
11type FormData = z.infer<typeof schema>
12
13export function MyForm() {
14 // 2. Call useForm with your schema
15 const {
16 register,
17 handleSubmit,
18 formState: { errors, isSubmitting },
19 } = useForm<FormData>({
20 resolver: zodResolver(schema),
21 })
22
23 const onSubmit: SubmitHandler<FormData> = async (data) => {
24 // Submit to your API
25 }
26
27 // 3. Render your fields
28 return (
29 <form onSubmit={handleSubmit(onSubmit)}>
30 <Input {...register('email')} />
31 {errors.email && <p>{errors.email.message}</p>}
32
33 <Input {...register('password')} />
34 {errors.password && <p>{errors.password.message}</p>}
35
36 <Button type="submit" loading={isSubmitting}>
37 Submit
38 </Button>
39 </form>
40 )
41}

Use register() for components with native change events (Input, Textarea, Checkbox). Use Controller for components with custom value APIs (Select, RadioGroup, Switch, Slider, DatePicker).

Anatomy

Two patterns for connecting Drivn components to your form.

1// register() — for Input, Textarea, Checkbox
2// These use forwardRef + native change events
3
4<Input
5 {...register('email')}
6 placeholder="you@example.com"
7/>

Components with native onChange events accept register() directly. Components with custom value signatures need Controller to bridge the gap.

1// Controller — for Select, RadioGroup, Switch, Slider, DatePicker
2// These have custom value/onChange signatures
3
4<Controller
5 name="role"
6 control={control}
7 render={({ field }) => (
8 <Select value={field.value} onChange={field.onChange}>
9 <Select.Trigger placeholder="Pick a role" />
10 <Select.Menu>
11 <Select.Option value="dev">Developer</Select.Option>
12 </Select.Menu>
13 </Select>
14 )}
15/>

Profile Form

A complex form combining register() and Controller across all Drivn form components.

March 2026

Input

Input uses forwardRef and native events — spread register() directly.

Textarea

Like Input, Textarea supports register() with no Controller needed.

Checkbox

Checkbox forwards its ref and fires a native change event. register() maps the boolean checked value to your schema.

Select

Select uses a custom onChange that receives a value string. Controller bridges field.value and field.onChange to the component.

Radio Group

RadioGroup fires onValueChange instead of onChange. Controller adapts this to React Hook Form's interface.

Switch

Switch onChange receives a boolean directly. Controller maps field.value to checked and field.onChange to the callback.

Slider

Slider onChange receives a number value. Controller connects field.value and field.onChange directly.

Date Picker

DatePicker uses selected and onSelect props. Controller maps field.value to selected and field.onChange to onSelect.

March 2026

Zod Schema

Common Zod v4 patterns for form schemas. Zod infers TypeScript types automatically — no manual type definitions needed.

1import { z } from 'zod'
2
3const schema = z.object({
4 // Top-level validators (Zod v4)
5 email: z.email('Enter a valid email'),
6 website: z.url('Enter a valid URL'),
7 id: z.uuid('Invalid ID format'),
8
9 // String with constraints
10 name: z.string().min(2, 'At least 2 characters'),
11 bio: z.string().max(200, 'Max 200 characters'),
12
13 // Enum
14 role: z.enum(['developer', 'designer', 'manager']),
15
16 // Boolean with validation
17 terms: z.boolean().refine((v) => v, 'You must accept'),
18
19 // Number (coerce from string input)
20 age: z.coerce.number().min(18).max(120),
21
22 // Date
23 startDate: z.date({ error: 'Pick a date' }),
24})
25
26// Infer TypeScript type from schema
27type FormData = z.infer<typeof schema>
28
29// Cross-field validation
30const passwordSchema = z
31 .object({
32 password: z.string().min(8),
33 confirm: z.string(),
34 })
35 .refine((d) => d.password === d.confirm, {
36 message: 'Passwords must match',
37 path: ['confirm'],
38 })

Error Messages

Display validation errors inline below each field. Zod provides the message, you just render it conditionally.

1{errors.email && (
2 <p className="mt-1 text-sm text-destructive">
3 {errors.email.message}
4 </p>
5)}
6
7// Or with Input's built-in error prop (if supported)
8<Input
9 {...register('email')}
10 error={errors.email?.message}
11/>