Skip to content
shadcn.io
shadcn.io is not affiliated with official shadcn/ui

Shadcn React Hook Form for React and Tailwind

Build forms in React using React Hook Form and Zod.

This guide will cover building forms using the Field component, adding schema validation with Zod, handling errors, ensuring accessibility, and more.

Demo

We’ll build a form with a text input and textarea. When you submit, the form data is validated and any errors will be shown.

Bug Report

Help us improve by reporting bugs you encounter.

0/100 characters

Include steps to reproduce, expected behavior, and what actually happened.

Approach

This form uses React Hook Form for state and Zod for validation. We'll build forms using the Field component, which gives you complete flexibility over the markup and styling.

  • Uses React Hook Form's useForm hook for form state management.
  • Uses the Controller component for controlled inputs.
  • Uses the Field components for building accessible forms.
  • Uses client-side validation by passing your Zod schema into resolver.

Anatomy

Typical structure: wrap each field with Controller, and the Field component.

<form onSubmit={form.handleSubmit(onSubmit)}>
  <FieldGroup>
    <Controller
      name="title"
      control={form.control}
      render={({ field, fieldState }) => (
        <Field invalid={fieldState.invalid}>
          <FieldLabel>Bug Title</FieldLabel>
          <Input
            {...field}
            placeholder="Login button not working on mobile"
            autoComplete="off"
          />
          <FieldDescription>
            Provide a concise title for your bug report.
          </FieldDescription>
          <FieldError>{fieldState.error?.message}</FieldError>
        </Field>
      )}
    />
  </FieldGroup>
  <Button type="submit">Submit</Button>
</form>

Form

Create a schema

Define your form shape with a Zod schema.

form.tsx
import * as z from "zod"

const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
})

Setup

Create the form with useForm from React Hook Form and pass your schema to the resolver option.

form.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";

const formSchema = z.object({
  // ...
});

export const BugReportForm = () => {
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      description: "",
    },
  });

  const onSubmit = (data: z.infer<typeof formSchema>) => {
    console.log(data);
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* Build the form here */}
    </form>
  );
};

Build

Build the form using the Controller from React Hook Form and the shadcn.io Field.

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@registry/react/components/toast";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group";

const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
});

export const BugReportForm = () => {
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      description: "",
    },
  });

  const onSubmit = (data: z.infer<typeof formSchema>) => {
    toast.info({
      id: "bug-report-submitted",
      title: "Bug submitted",
      description: (
        <pre className="mt-2">
          <code>{JSON.stringify(data, null, 2)}</code>
        </pre>
      ),
    });
  };

  return (
    <Card asChild className="w-full sm:max-w-md">
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <CardHeader
          description="Help us improve by reporting bugs you encounter."
          title="Bug Report"
        />
        <CardContent>
          <FieldGroup>
            <Controller
              control={form.control}
              name="title"
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Bug Title</FieldLabel>
                  <Input
                    {...field}
                    autoComplete="off"
                    placeholder="Login button not working on mobile"
                  />
                  <FieldError>{fieldState.error?.message}</FieldError>
                </Field>
              )}
            />
            <Controller
              control={form.control}
              name="description"
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Description</FieldLabel>
                  <InputGroup>
                    <InputGroupTextarea
                      {...field}
                      className="min-h-24 resize-none"
                      placeholder="I'm having an issue with the login button on mobile."
                      rows={6}
                    />
                    <InputGroupAddon align="block-end">
                      <InputGroupText className="tabular-nums">
                        {field.value.length}/100 characters
                      </InputGroupText>
                    </InputGroupAddon>
                  </InputGroup>
                  <FieldDescription>
                    Include steps to reproduce, expected behavior, and what
                    actually happened.
                  </FieldDescription>
                  <FieldError>{fieldState.error?.message}</FieldError>
                </Field>
              )}
            />
          </FieldGroup>
        </CardContent>
        <CardFooter>
          <Button onClick={() => form.reset()} variant="outline">
            Reset
          </Button>
          <Button type="submit">Submit</Button>
        </CardFooter>
      </form>
    </Card>
  );
};

Done

That's it. You now have a fully accessible form with client-side validation.

When you submit the form, the onSubmit function will be called with the validated form data. If the form is invalid, React Hook Form will display the errors on field.state.error for FieldError.

Validation

Client-side

React Hook Form validates your form data using the Zod schema. Define a schema and pass it to the resolver option of the useForm hook.

example-form.tsx
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"

const formSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
})

export const ExampleForm = () => {
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      description: "",
    },
  })
}

Modes

Configure when validation runs via the mode option:

form.tsx
const form = useForm({
  resolver: zodResolver(formSchema),
  mode: "onChange",
})
ModeDescription
"onChange"Validation triggers on every change.
"onBlur"Validation triggers on blur.
"onSubmit"Validation triggers on submit (default).
"onTouched"Validation triggers on first blur, then on every change.
"all"Validation triggers on blur and change.

Displaying Errors

Display errors next to the field using FieldError. For styling and accessibility:

  • Add the invalid prop to the Field component.
  • Don't need to add the invalid prop to the form control such as Input, Checkbox, etc.
form.tsx
<Controller
  name="email"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldLabel>Email</FieldLabel>
      <Input {...field} type="email" />
     <FieldError>{fieldState.error?.message}</FieldError>
    </Field>
  )}
/>

Different types of fields

Input

  • Spread the field object onto the Input component.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Textarea

  • Spread the field object onto the Textarea component.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

NativeSelect

  • Spread the field object onto the NativeSelect component.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Select

  • Wire field.value and field.onChange to Select.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Checkbox

  • Wire field.value and field.onChange to Checkbox.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.
  • Remember to add data-slot="checkbox-group" to the FieldGroup component for proper styling and spacing.

Radio group

  • Wire field.value and field.onChange to RadioGroup.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Switch

  • Wire field.value and field.onChange to Switch.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

NumberInput

  • Wire field.value and field.onChange to NumberInput.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Slider

  • Wire field.value and field.onChange to Slider.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Combobox

  • Wire field.value and field.onChange to Combobox.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Autocomplete

  • Wire field.value and field.onChange to Autocomplete.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Date Picker

  • Wire field.value and field.onChange to DatePicker.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Input OTP

  • Wire field.value and field.onChange to InputOTP.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Rating

  • Wire field.value and field.onChange to Rating.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

File Upload

  • Wire field.value and field.onChange to FileUpload.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Complex Forms

Here is an example of a more complex form with multiple fields and validation.

Resetting the Form

Use form.reset() to reset the form to its default values.

<Button type="button" variant="outline" onClick={() => form.reset()}>
  Reset
</Button>

Array Fields

React Hook Form provides a useFieldArray hook for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.

Using useFieldArray

Use the useFieldArray hook to manage array fields. It provides fields, append, and remove methods.

form.tsx
import { useFieldArray, useForm } from "react-hook-form";

export const ExampleForm = () => {
  const form = useForm({
    // ... form config
  });

  const { fields, append, remove } = useFieldArray({
    control: form.control,
    name: "emails",
  });
}

Array Field Structure

Wrap your array fields in a FieldSet with a FieldLegend and FieldDescription.

form.tsx
<FieldSet className="gap-4">
  <FieldLegend variant="label">Email Addresses</FieldLegend>
  <FieldDescription>
    Add up to 5 email addresses where we can contact you.
  </FieldDescription>
  <FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup>
</FieldSet>

Controller Pattern for Array Items

Map over the fields array and use Controller for each item. Make sure to use field.id as the key.

form.tsx
{
  fields.map((field, index) => (
    <Controller
      key={field.id}
      name={`emails.${index}.address`}
      control={form.control}
      render={({ field: controllerField, fieldState }) => (
        <Field invalid={fieldState.invalid} orientation="horizontal">
          <FieldContent>
            <InputGroup>
              <InputGroupInput
                {...controllerField}
                id={`form-rhf-array-email-${index}`}
                aria-invalid={fieldState.invalid}
                placeholder="[email protected]"
                type="email"
                autoComplete="email"
              />
              {/* Remove button */}
            </InputGroup>
            <FieldError>{fieldState.error?.message}</FieldError>
          </FieldContent>
        </Field>
      )}
    />
  ))}

Adding Items

Use the append method to add new items to the array.

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => append({ address: "" })}
  disabled={fields.length >= 5}
>
  Add Email Address
</Button>

Removing Items

Use the remove method to remove items from the array. Add the remove button conditionally.

form.tsx
{
  fields.length > 1 && (
    <InputGroupAddon align="inline-end">
      <InputGroupButton
        type="button"
        variant="ghost"
        size="icon-xs"
        onClick={() => remove(index)}
        aria-label={`Remove email ${index + 1}`}
      >
        <XIcon />
      </InputGroupButton>
    </InputGroupAddon>
  )}
}

Array Validation

Use Zod's array method to validate array fields.

form.tsx
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email("Enter a valid email address."),
      })
    )
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
});