React Hook Form

Building forms with React Hook Form and Zod.

Forms are tricky. They are one of the most common things you'll build in a web application, but also one of the most complex.

Well-designed HTML forms are:

  • Well-structured and semantically correct.
  • Easy to use and navigate (keyboard).
  • Accessible with ARIA attributes and proper labels.
  • Has support for client and server side validation.
  • Well-styled and consistent with the rest of the application.

In this guide, we will take a look at building forms with react-hook-form and zod. We're going to use a <FormField> component to compose accessible forms using Radix UI components.

Features

The <Form /> component is a wrapper around the react-hook-form library. It provides a few things:

  • Composable components for building forms.
  • A <FormField /> component for building controlled form fields.
  • Form validation using zod.
  • Handles accessibility and error messages.
  • Uses React.useId() for generating unique IDs.
  • Applies the correct aria attributes to form fields based on states.
  • Built to work with all Radix UI components.
  • Bring your own schema library. We use zod but you can use anything you want.
  • You have full control over the markup and styling.

Anatomy

<Form>
  <FormField
    control={...}
    name="..."
    render={() => (
      <FormItem>
        <FormLabel />
        <FormControl>
          { /* Your form field */}
        </FormControl>
        <FormDescription />
        <FormMessage />
      </FormItem>
    )}
  />
</Form>

Example

const form = useForm()
 
<FormField
  control={form.control}
  name="username"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Username</FormLabel>
      <FormControl>
        <Input placeholder="shadcn" {...field} />
      </FormControl>
      <FormDescription>This is your public display name.</FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>

Installation

Install the following dependencies:

npm install @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod

Copy and paste the following code into your project.

import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import clsx from 'clsx';
import {
    ComponentPropsWithoutRef,
    ElementRef,
    HTMLAttributes,
    createContext,
    forwardRef,
    useContext,
    useId,
} from 'react';
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form';
import { Label } from '../label';
import styles from './styles.module.scss';
 
const Form = FormProvider;
 
type FormFieldContextValue<
    TFieldValues extends FieldValues = FieldValues,
    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
    name: TName;
};
 
const FormFieldContext = createContext<FormFieldContextValue>({} as FormFieldContextValue);
 
const FormField = <
    TFieldValues extends FieldValues = FieldValues,
    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
    ...props
}: ControllerProps<TFieldValues, TName>) => {
    return (
        <FormFieldContext.Provider value={{ name: props.name }}>
            <Controller {...props} />
        </FormFieldContext.Provider>
    );
};
 
const useFormField = () => {
    const fieldContext = useContext(FormFieldContext);
    const itemContext = useContext(FormItemContext);
    const { getFieldState, formState } = useFormContext();
 
    const fieldState = getFieldState(fieldContext.name, formState);
 
    if (!fieldContext) {
        throw new Error('useFormField should be used within <FormField>');
    }
 
    const { id } = itemContext;
 
    return {
        id,
        name: fieldContext.name,
        formItemId: `${id}-form-item`,
        formDescriptionId: `${id}-form-item-description`,
        formMessageId: `${id}-form-item-message`,
        ...fieldState,
    };
};
 
type FormItemContextValue = {
    id: string;
};
 
const FormItemContext = createContext<FormItemContextValue>({} as FormItemContextValue);
 
const FormItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => {
    const id = useId();
 
    return (
        <FormItemContext.Provider value={{ id }}>
            <div ref={ref} className={clsx(styles.form__item, className)} {...props} />
        </FormItemContext.Provider>
    );
});
FormItem.displayName = 'FormItem';
 
const FormLabel = forwardRef<
    ElementRef<typeof LabelPrimitive.Root>,
    ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
    const { error, formItemId } = useFormField();
 
    return <Label ref={ref} className={clsx(error && styles.form__label, className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = 'FormLabel';
 
const FormControl = forwardRef<ElementRef<typeof Slot>, ComponentPropsWithoutRef<typeof Slot>>(({ ...props }, ref) => {
    const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
 
    return (
        <Slot
            ref={ref}
            id={formItemId}
            aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
            aria-invalid={!!error}
            {...props}
        />
    );
});
FormControl.displayName = 'FormControl';
 
const FormDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
    ({ className, ...props }, ref) => {
        const { formDescriptionId } = useFormField();
 
        return <p ref={ref} id={formDescriptionId} className={clsx(styles.form__description, className)} {...props} />;
    }
);
FormDescription.displayName = 'FormDescription';
 
const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
    ({ className, children, ...props }, ref) => {
        const { error, formMessageId } = useFormField();
        const body = error ? String(error?.message) : children;
 
        if (!body) {
            return null;
        }
 
        return (
            <p ref={ref} id={formMessageId} className={clsx(styles.form__message, className)} {...props}>
                {body}
            </p>
        );
    }
);
FormMessage.displayName = 'FormMessage';
 
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField };

Update the import paths to match your project setup.

Usage

Create a form schema

Define the shape of your form using a Zod schema. You can read more about using Zod in the Zod documentation.

'use client';
 
import { z } from 'zod';
 
const formSchema = z.object({
    username: z.string().min(2).max(50),
});

Define a form

Use the useForm hook from react-hook-form to create a form.

'use client';
 
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
 
const formSchema = z.object({
    username: z.string().min(2, {
        message: 'Username must be at least 2 characters.',
    }),
});
 
export function ProfileForm() {
    // 1. Define your form.
    const form = useForm<z.infer<typeof formSchema>>({
        resolver: zodResolver(formSchema),
        defaultValues: {
            username: '',
        },
    });
 
    // 2. Define a submit handler.
    function onSubmit(values: z.infer<typeof formSchema>) {
        // Do something with the form values.
        // ✅ This will be type-safe and validated.
        console.log(values);
    }
}

Since FormField is using a controlled component, you need to provide a default value for the field. See the React Hook Form docs to learn more about controlled components.

Build your form

We can now use the <Form /> components to build our form.

'use client';
 
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
 
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
 
const formSchema = z.object({
    username: z.string().min(2, {
        message: 'Username must be at least 2 characters.',
    }),
});
 
export function ProfileForm() {
    // ...
 
    return (
        <Form {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)}>
                <FormField
                    control={form.control}
                    name='username'
                    render={({ field }) => (
                        <FormItem>
                            <FormLabel>Username</FormLabel>
                            <FormControl>
                                <Input placeholder='shadcn' {...field} />
                            </FormControl>
                            <FormDescription>This is your public display name.</FormDescription>
                            <FormMessage />
                        </FormItem>
                    )}
                />
                <Button type='submit'>Submit</Button>
            </form>
        </Form>
    );
}

Done

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

This is your public display name.

Update the import paths to match your project setup.

Examples

See the following links for more examples on how to use the <Form /> component with other components: