How to Integrate shadcn UI Components with React Hook Form and Zod: A Complete Guide

shadcn/ui provides a lightweight Form abstraction built on top of react-hook-form that connects UI components like Input, Textarea, and Select to validation libraries through composable primitives including FormField, FormItem, and FormMessage.

The shadcn/ui repository ships with a type-safe form system that bridges the gap between accessible UI components and robust validation. By leveraging react-hook-form as the foundation and optionally adding Zod for schema validation, you can build complex forms with minimal boilerplate while maintaining full TypeScript inference.

Understanding the shadcn UI Form Architecture

The form system is implemented in apps/v4/registry/new-york-v4/ui/form.tsx and consists of thin wrappers around react-hook-form's API. These wrappers handle accessibility attributes, error messaging, and component composition while preserving the full power of the underlying library.

Core Primitives and Their Roles

The Form abstraction exports seven key components:

  • Form: An alias for FormProvider from react-hook-form that exposes register, control, and handleSubmit to child components.
  • FormField: Uses Controller to connect a specific field name to the form state and stores the name in FormFieldContext.
  • FormItem: Generates a unique ID via React.useId() and provides it to child components through FormItemContext.
  • FormLabel: Associates the label with the input using the generated ID.
  • FormControl: Forwards the ID and ARIA attributes (aria-invalid, aria-describedby) to the actual UI component.
  • FormDescription: Provides supplementary text linked via aria-describedby.
  • FormMessage: Displays validation errors from the field state.

How FormField Connects to react-hook-form

The FormField component in apps/v4/registry/new-york-v4/ui/form.tsx (lines 32-43) implements the bridge between your UI and react-hook-form:

// Simplified structure from the source
<Controller
  name={name}
  control={control}
  render={({ field, fieldState }) => (
    <FormFieldContext.Provider value={{ name, fieldState }}>
      {children}
    </FormFieldContext.Provider>
  )}
/>

This pattern ensures that any component inside FormField can access the field's state through the useFormField hook, which reads both FormFieldContext and FormItemContext (lines 45-66 in the same file).

Setting Up Zod Validation with shadcn UI Forms

To add schema validation, install the Zod resolver and define your schema. The zodResolver from @hookform/resolvers/zod integrates seamlessly with the shadcn form primitives.

npm install zod @hookform/resolvers/zod

The following example from deprecated/www/registry/new-york/examples/textarea-form.tsx demonstrates a complete implementation:

import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"

import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormDescription,
  FormMessage,
} from "@/registry/new-york/ui/form"
import { Textarea } from "@/registry/new-york/ui/textarea"
import { Button } from "@/registry/new-york/ui/button"

const schema = z.object({
  bio: z
    .string()
    .min(10, "Bio must be at least 10 characters")
    .max(160, "Bio must be ≤ 160 characters"),
})

export function BioForm() {
  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
  })

  function onSubmit(data: z.infer<typeof schema>) {
    console.log("Submitted:", data)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="bio"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Bio</FormLabel>
              <FormControl>
                <Textarea placeholder="Tell us about yourself" {...field} />
              </FormControl>
              <FormDescription>
                Write a short paragraph (10-160 characters).
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Save</Button>
      </form>
    </Form>
  )
}

In this implementation, FormMessage automatically displays errors generated by Zod when validation fails, while FormDescription provides context to assistive technologies via aria-describedby.

Working with Different Input Types

The shadcn form system is component-agnostic. You can wrap any input component that accepts value and onChange (or onValueChange) props.

Text Inputs and Textareas

Standard text inputs from apps/v4/registry/new-york-v4/ui/input.tsx work immediately when placed inside FormControl:

<FormControl>
  <Input type="email" placeholder="you@example.com" {...field} />
</FormControl>

The FormControl component automatically injects the id, aria-invalid (when errors exist), and aria-describedby (linking to description and error messages) attributes.

Select Components with Enum Validation

For select inputs, use the Select component from the registry. The following pattern validates against a Zod enum:

import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"

import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage,
} from "@/registry/new-york/ui/form"
import { Select } from "@/registry/new-york/ui/select"
import { Button } from "@/registry/new-york/ui/button"

const schema = z.object({
  role: z.enum(["admin", "editor", "viewer"], {
    errorMap: () => ({ message: "Select a valid role" }),
  }),
})

export function RoleForm() {
  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Role</FormLabel>
              <FormControl>
                <Select
                  value={field.value}
                  onValueChange={field.onChange}
                  placeholder="Choose a role"
                >
                  <Select.Item value="admin">Admin</Select.Item>
                  <Select.Item value="editor">Editor</Select.Item>
                  <Select.Item value="viewer">Viewer</Select.Item>
                </Select>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  )
}

The Select component's value and onValueChange props map directly to the Controller-provided field object, ensuring type-safe two-way binding.

Key Implementation Files in the shadcn/ui Repository

Understanding the source structure helps when debugging or extending the form system:

These files constitute the bridge between shadcn UI's visual components and the robust validation capabilities of React Hook Form and Zod.

Summary

  • shadcn/ui provides a thin, type-safe abstraction over react-hook-form through composable primitives like FormField, FormItem, and FormMessage.
  • The Form component acts as a FormProvider, while FormField uses Controller to register fields and expose state to UI components.
  • Zod integration requires only the zodResolver from @hookform/resolvers/zod; validation errors automatically surface in FormMessage components.
  • All primitives are unopinionated about the specific UI component used, allowing seamless swapping of Input, Textarea, Select, or custom components.
  • Source files in apps/v4/registry/new-york-v4/ui/form.tsx implement the context wiring and accessibility attributes (aria-invalid, aria-describedby) that make forms accessible by default.

Frequently Asked Questions

Do I need to use Zod with shadcn/ui forms?

No. While Zod provides schema-based validation, the shadcn form primitives work with any validation strategy supported by react-hook-form. You can use Yup, Joi, custom validation functions, or even native HTML5 validation attributes. The FormMessage component will display any error object returned by your chosen resolver.

How does the Form component handle accessibility?

The form system automatically manages ARIA attributes through the useFormField hook implemented in apps/v4/registry/new-york-v4/ui/form.tsx. When a validation error occurs, FormControl sets aria-invalid="true" on the input. FormDescription and FormMessage generate unique IDs that are referenced by aria-describedby, ensuring screen readers announce both helper text and error messages.

Can I use shadcn form primitives with custom components?

Yes. The FormField component uses react-hook-form's Controller, which only requires that your custom component accepts value and onChange (or onValueChange) props. Wrap your component inside FormControl to automatically receive the generated ID and ARIA attributes. This pattern works for complex components like date pickers, rich text editors, or custom select implementations.

Where are the form primitives located in the shadcn/ui codebase?

The core form primitives reside in apps/v4/registry/new-york-v4/ui/form.tsx for the v4 registry, with legacy versions available in deprecated/www/registry/new-york/ui/form.tsx. The useFormField hook and all context providers (FormFieldContext, FormItemContext) are defined in these files, making them the authoritative source for understanding how the abstraction layer works.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →