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
FormProviderfrom react-hook-form that exposesregister,control, andhandleSubmitto child components. - FormField: Uses
Controllerto connect a specific field name to the form state and stores the name inFormFieldContext. - FormItem: Generates a unique ID via
React.useId()and provides it to child components throughFormItemContext. - 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:
apps/v4/registry/new-york-v4/ui/form.tsx: Contains the core primitives (Form,FormField,FormItem,FormLabel,FormControl,FormDescription,FormMessage) and theuseFormFieldhook. This is whereFormProviderandControllerare wrapped.apps/v4/registry/new-york-v4/ui/input.tsx: Standard text input component that receives forwarded refs and ARIA attributes fromFormControl.apps/v4/registry/new-york-v4/ui/textarea.tsx: Multi-line text input implementation.deprecated/www/registry/new-york/examples/textarea-form.tsx: Complete working example demonstrating Zod integration with the form primitives.apps/v4/registry/new-york-v4/ui/select.tsx(or equivalent registry path): Select component implementation that acceptsvalueandonValueChangeprops for Controller compatibility.
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, andFormMessage. - The
Formcomponent acts as aFormProvider, whileFormFieldusesControllerto register fields and expose state to UI components. - Zod integration requires only the
zodResolverfrom@hookform/resolvers/zod; validation errors automatically surface inFormMessagecomponents. - 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.tsximplement 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →