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",
});
ModeValidation triggers

onSubmit (default)

When the form is submitted
onBlurWhen a field loses focus
onChangeOn every change
onTouchedOn first blur, then on every change
allOn 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>
  );
}