Kosorikōsori alpha

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 dependencies

Install the @radix-ui/react-label, @radix-ui/react-slot, react-hook-form, @hookform/resolvers, and zod packages.

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

Copy-paste the component

Copy and paste the component code in a .tsx file.

import type { Root } from '@radix-ui/react-label';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { createContext, forwardRef, useContext, useId } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
import { tv } from 'tailwind-variants';
 
import { Label } from '@kosori/ui/label';
 
const formStyles = tv({
  slots: {
    item: 'space-y-2',
    label: '',
    description: 'text-sm text-grey-text',
    message: 'text-sm font-medium text-error-solid',
  },
  variants: {
    error: {
      true: {
        label: 'text-error-solid',
      },
    },
  },
});
 
const { item, label, description, message } = formStyles();
 
/**
 * Form component that provides context for form fields.
 *
 * @param {React.ComponentPropsWithoutRef<typeof FormProvider>} props - The props for the Form component.
 *
 * @example
 * <>
 *   <Form>
 *     <FormField control={...} name='...' render={() => <FormControl />} />
 *   </Form>
 * </>
 *
 * @see {@link https://dub.sh/ui-form Form Docs} for further information.
 */
export const Form = FormProvider;
 
type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
  name: TName;
};
 
export const FormFieldContext = createContext<FormFieldContextValue>(
  {} as FormFieldContextValue,
);
 
/**
 * FormField component that wraps a Controller from react-hook-form.
 *
 * @param {ControllerProps<TFieldValues, TName>} props - The props for the FormField component.
 *
 * @example
 * <FormField
 *   control={...}
 *   name='...'
 *   render={...} />
 * </>
 */
export 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>
  );
};
 
type FormItemContextValue = {
  id: string;
};
 
export const FormItemContext = createContext<FormItemContextValue>(
  {} as FormItemContextValue,
);
 
export const useFormField = () => {
  const fieldContext = useContext(FormFieldContext);
  const itemContext = useContext(FormItemContext);
  const { getFieldState, formState } = useFormContext();
 
  const fieldState = getFieldState(fieldContext.name, formState);
 
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  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 FormItemRef = HTMLDivElement;
type FormItemProps = React.HTMLAttributes<HTMLDivElement>;
 
/**
 * FormItem component that serves as a wrapper for form fields.
 *
 * @param {FormItemProps} props - The props for the FormItem component.
 *
 * @example
 * <>
 *   <FormItem>
 *     <FormLabel />
 *     <FormControl />
 *     <FormDescription />
 *     <FormMessage />
 *   </FormItem>
 * </>
 */
export const FormItem = forwardRef<FormItemRef, FormItemProps>(
  ({ className, ...props }, ref) => {
    const id = useId();
 
    return (
      <FormItemContext.Provider value={{ id }}>
        <div ref={ref} className={item({ className })} {...props} />
      </FormItemContext.Provider>
    );
  },
);
 
FormItem.displayName = 'FormItem';
 
type FormLabelRef = React.ElementRef<typeof Root>;
type FormLabelProps = React.ComponentPropsWithoutRef<typeof Root>;
 
/**
 * FormLabel component that renders a label for the form field.
 *
 * @param {FormLabelProps} props - The props for the FormLabel component.
 *
 * @example
 * <FormLabel>Label</FormLabel>
 */
export const FormLabel = forwardRef<FormLabelRef, FormLabelProps>(
  ({ className, ...props }, ref) => {
    const { error, formItemId } = useFormField();
 
    return (
      <Label
        ref={ref}
        className={label({ className, error: error ? true : false })}
        htmlFor={formItemId}
        {...props}
      />
    );
  },
);
 
FormLabel.displayName = 'FormLabel';
 
type FormControlRef = React.ElementRef<typeof Slot>;
type FormControlProps = React.ComponentPropsWithoutRef<typeof Slot>;
 
/**
 * FormControl component that wraps the form control element.
 *
 * @param {FormControlProps} props - The props for the FormControl component.
 *
 * @example
 * <>
 *   <FormControl>
 *     {...}
 *   </FormControl>
 */
export const FormControl = forwardRef<FormControlRef, FormControlProps>(
  ({ ...props }, ref) => {
    const { error, formItemId, formDescriptionId, formMessageId } =
      useFormField();
 
    return (
      <Slot
        ref={ref}
        aria-describedby={
          !error
            ? `${formDescriptionId}`
            : `${formDescriptionId} ${formMessageId}`
        }
        aria-invalid={!!error}
        id={formItemId}
        {...props}
      />
    );
  },
);
 
FormControl.displayName = 'FormControl';
 
type FormDescriptionRef = HTMLParagraphElement;
type FormDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
 
/**
 * FormDescription component that provides additional information about the form field.
 *
 * @param {FormDescriptionProps} props - The props for the FormDescription component.
 *
 * @example
 * <FormDescription>Description</FormDescription>
 */
export const FormDescription = forwardRef<
  FormDescriptionRef,
  FormDescriptionProps
>(({ className, ...props }, ref) => {
  const { formDescriptionId } = useFormField();
 
  return (
    <p
      ref={ref}
      className={description({ className })}
      id={formDescriptionId}
      {...props}
    />
  );
});
 
FormDescription.displayName = 'FormDescription';
 
type FormMessageRef = HTMLParagraphElement;
type FormMessageProps = React.HTMLAttributes<HTMLParagraphElement>;
 
/**
 * FormMessage component that displays error messages for the form field.
 *
 * @param {FormMessageProps} props - The props for the FormMessage component.
 *
 * @example
 * <FormMessage>Error message</FormMessage>
 */
export const FormMessage = forwardRef<FormMessageRef, FormMessageProps>(
  ({ className, children, ...props }, ref) => {
    const { error, formMessageId } = useFormField();
    const body = error ? String(error.message) : children;
 
    if (!body) {
      return null;
    }
 
    return (
      <p
        ref={ref}
        className={message({ className })}
        id={formMessageId}
        {...props}
      >
        {body}
      </p>
    );
  },
);
 
FormMessage.displayName = 'FormMessage';

Update import paths

Update the @kosori/ui import paths to fit your project structure, for example, using ~/components/ui.

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 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 const ProfileForm = () => {

  // 1. Define your form.
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: '',
    },
  });
 

  // 2. Define a submit handler.
  const 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 const ProfileForm = () => {
  // ...
 

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
        <FormField
          control={form.control}
          name='username'
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder='codingcodax' {...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.

Examples

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

On this page