React Hook Form
Building forms with React Hook Form and Zod schema validation.
React Hook Form manages form state, Zod validates values against a schema, and Hummingbird's Field parts render the markup.
Installation
install React Hook Form, Zod, and the resolver package:
pnpm add react-hook-form @hookform/resolvers zod
Demo
tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import {
Button,
Checkbox,
Field,
Input,
Radio,
RadioGroup,
Select,
Textarea,
toast,
} from "@hummingbirdui/react";
const formSchema = z.object({
username: z.string().min(2, "Username must be at least 2 characters."),
bio: z
.string()
.min(10, "Bio must be at least 10 characters.")
.max(160, "Bio must be at most 160 characters."),
plan: z.enum(["free", "pro", "team"], "Please select a plan."),
notify: z.enum(["all", "mentions", "none"], "Please select an option."),
terms: z.boolean().refine(Boolean, "The terms must be accepted."),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormDemo() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
bio: "",
plan: "" as FormValues["plan"],
notify: "" as FormValues["notify"],
terms: false,
},
});
function onSubmit(data: FormValues) {
toast("Form submitted successfully!");
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto w-full max-w-sm space-y-4"
>
<Controller
name="username"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="form-username">Username</Field.Label>
<Input
{...field}
id="form-username"
placeholder="hummingbird"
state={fieldState.invalid ? "invalid" : undefined}
/>
{fieldState.invalid && (
<Field.Text variant="invalid">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Controller
name="bio"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="form-bio">Bio</Field.Label>
<Textarea
{...field}
id="form-bio"
rows={3}
placeholder="Tell everyone a little about this account."
state={fieldState.invalid ? "invalid" : undefined}
/>
{fieldState.invalid && (
<Field.Text variant="invalid">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Controller
name="plan"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="form-plan">Plan</Field.Label>
<Select
{...field}
id="form-plan"
state={fieldState.invalid ? "invalid" : undefined}
>
<option value="" disabled>
Choose a plan
</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</Select>
{fieldState.invalid && (
<Field.Text variant="invalid">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Controller
name="notify"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label>Notify me about</Field.Label>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
>
<Radio inline value="all" label="All" />
<Radio inline value="mentions" label="Mentions" />
<Radio inline value="none" label="Nothing" />
</RadioGroup>
{fieldState.invalid && (
<Field.Text variant="invalid" className="block">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Controller
name="terms"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Checkbox
label="Accept terms and conditions"
checked={field.value}
onChange={field.onChange}
className={fieldState.invalid ? "is-invalid" : undefined}
/>
</Field>
)}
/>
<div className="flex gap-2">
<Button type="submit">Submit</Button>
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
</div>
</form>
);
}Anatomy
The example shows how to compose a Field with Controller from React Hook Form. The field object spreads the input props, and fieldState provides validation state and error messages.
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="title">Title</Field.Label>
<Input
{...field}
id="title"
state={fieldState.invalid ? "invalid" : undefined}
/>
{fieldState.invalid && (
<Field.Text variant="invalid">{fieldState.error?.message}</Field.Text>
)}
</Field>
)}
/>Building a form
Create a form schema
Define a Zod schema for the form fields, and infer the TypeScript type from it.
import { z } from "zod";
const formSchema = z.object({
username: z.string().min(2, "Username must be at least 2 characters."),
});
type FormValues = z.infer<typeof formSchema>;Set up the form
The useForm hook initializes the form with the Zod resolver and default values. The onSubmit function receives validated and typed data.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { username: "" },
});
function onSubmit(data: FormValues) {
// data is validated and typed
}Build the form
Now build the form with Controller fields and a submit button. The handleSubmit function wraps the onSubmit callback.
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Controller fields */}
<Button type="submit">Submit</Button>
</form>Validation modes
Use the mode option to control when validation occurs. The default is onSubmit.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { username: "" },
mode: "onBlur",
});| Mode | Validation triggers |
|---|---|
| When the form is submitted |
onBlur | When a field loses focus |
onChange | On every change |
onTouched | On first blur, then on every change |
all | On both blur and change |
Date picker
The calendar binds through selected/onSelect, closing the popover after a date is picked.
tsx
import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import {
Button,
Calendar,
DatePicker,
Field,
toast,
} from "@hummingbirdui/react";
const formSchema = z.object({
dob: z.date("A date of birth is required."),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormDatePicker() {
const [open, setOpen] = React.useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { dob: undefined },
});
function onSubmit(data: FormValues) {
toast("Date of birth saved", { description: format(data.dob, "PPP") });
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto w-full max-w-sm space-y-4"
>
<Controller
name="dob"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="form-dob">Date of birth</Field.Label>
<DatePicker open={open} onOpenChange={setOpen}>
<DatePicker.Trigger
id="form-dob"
placeholder="Pick a date"
className={fieldState.invalid ? "is-invalid" : undefined}
>
{field.value && format(field.value, "PPP")}
</DatePicker.Trigger>
<DatePicker.Content>
<Calendar
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date);
setOpen(false);
}}
/>
</DatePicker.Content>
</DatePicker>
{fieldState.invalid && (
<Field.Text variant="invalid">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Button type="submit">Submit</Button>
</form>
);
}Slider
Slider holds a number[] value bound with value/onValueChange.
tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button, Field, Slider, toast } from "@hummingbirdui/react";
const formSchema = z.object({
budget: z
.array(z.number())
.refine((value) => value[0] >= 25, "Allocate a budget of at least $25."),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormSlider() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { budget: [10] },
});
function onSubmit(data: FormValues) {
toast("Budget saved", { description: `Monthly budget: $${data.budget[0]}` });
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto w-full max-w-sm space-y-4"
>
<Controller
name="budget"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label>Budget: ${field.value[0]}</Field.Label>
<Slider
value={field.value}
onValueChange={field.onChange}
max={100}
step={5}
aria-label="Budget"
/>
{fieldState.invalid && (
<Field.Text variant="invalid" className="block">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Button type="submit">Submit</Button>
</form>
);
}File uploader
Zod v4 validates files natively — z.file() supports .max() for byte size and .mime() for exact MIME types.
tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { Upload } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button, Field, FileUploader, toast } from "@hummingbirdui/react";
const formSchema = z.object({
attachments: z
.array(
z
.file()
.max(1_000_000, "Each image must be 1 MB or smaller.")
.mime(["image/png", "image/jpeg"], "Only PNG and JPEG are allowed."),
)
.min(1, "Add at least one image."),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormFileUploader() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { attachments: [] },
});
function onSubmit(data: FormValues) {
toast("Images uploaded", {
description: data.attachments.map((file) => file.name).join(", "),
});
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto w-full max-w-md space-y-4"
>
<Controller
name="attachments"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label>Images</Field.Label>
<FileUploader
files={field.value}
onFilesChange={field.onChange}
multiple
>
<FileUploader.Dropzone>
<FileUploader.Message>
<div className="flex items-center justify-center gap-2">
<Upload className="size-4" />
<p className="text-xs mb-0">
Drop images here or browse from device
</p>
</div>
</FileUploader.Message>
</FileUploader.Dropzone>
<FileUploader.List />
</FileUploader>
{fieldState.invalid && (
<Field.Text variant="invalid" className="block">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Button type="submit">Submit</Button>
</form>
);
}Server errors
setError("root", ...) stores a form-level error rendered in an Alert; it clears on the next submit. formState.isSubmitting disables the button while the request is in flight.
tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Alert, Button, Field, Input, toast } from "@hummingbirdui/react";
const formSchema = z.object({
email: z.email("Enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
});
type FormValues = z.infer<typeof formSchema>;
export default function FormServerError() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "" },
});
const { errors, isSubmitting } = form.formState;
async function onSubmit(data: FormValues) {
await new Promise((resolve) => setTimeout(resolve, 1200));
if (data.email !== "demo@example.com") {
form.setError("root", {
message: "Sign in failed. Use demo@example.com with any password.",
});
return;
}
toast("Signed in successfully");
}
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto w-full max-w-sm space-y-4"
>
{errors.root && <Alert color="danger">{errors.root.message}</Alert>}
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="form-email">Email</Field.Label>
<Input
{...field}
id="form-email"
type="email"
placeholder="demo@example.com"
state={fieldState.invalid ? "invalid" : undefined}
/>
{fieldState.invalid && (
<Field.Text variant="invalid">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<Field.Label htmlFor="form-password">Password</Field.Label>
<Input
{...field}
id="form-password"
type="password"
state={fieldState.invalid ? "invalid" : undefined}
/>
{fieldState.invalid && (
<Field.Text variant="invalid">
{fieldState.error?.message}
</Field.Text>
)}
</Field>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in…" : "Sign in"}
</Button>
</form>
);
}