React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

shadcnformreact-hook-formzodui

shadcn Form

Build forms with shadcn's Form component — react-hook-form + Zod + accessible UI components, all wired together.

Recipe

Quick-reference recipe card — copy-paste ready.

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
 
const Schema = z.object({
  username: z.string().min(2, "At least 2 characters"),
  email: z.string().email("Invalid email"),
});
 
type FormData = z.infer<typeof Schema>;
 
export function MyForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(Schema),
    defaultValues: { username: "", email: "" },
  });
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit((data) => console.log(data))} className="space-y-4">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="johndoe" {...field} />
              </FormControl>
              <FormDescription>Your public display name.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

When to reach for this: When you use shadcn/ui and want consistent, accessible form fields with built-in error display, labels, and descriptions.

Working Example

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
 
const FeedbackSchema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email"),
  category: z.enum(["bug", "feature", "question", "other"], {
    required_error: "Select a category",
  }),
  message: z.string().min(10, "At least 10 characters").max(1000),
  priority: z.enum(["low", "medium", "high"]).default("medium"),
  subscribe: z.boolean().default(false),
});
 
type FeedbackData = z.infer<typeof FeedbackSchema>;
 
export function FeedbackForm() {
  const form = useForm<FeedbackData>({
    resolver: zodResolver(FeedbackSchema),
    defaultValues: {
      name: "",
      email: "",
      message: "",
      priority: "medium",
      subscribe: false,
    },
  });
 
  async function onSubmit(data: FeedbackData) {
    await new Promise((r) => setTimeout(r, 1000));
    console.log("Feedback:", data);
    form.reset();
  }
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg space-y-6">
        <div className="grid grid-cols-2 gap-4">
          <FormField
            control={form.control}
            name="name"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Name</FormLabel>
                <FormControl>
                  <Input placeholder="Your name" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input placeholder="you@example.com" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>
 
        <div className="grid grid-cols-2 gap-4">
          <FormField
            control={form.control}
            name="category"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Category</FormLabel>
                <Select onValueChange={field.onChange} defaultValue={field.value}>
                  <FormControl>
                    <SelectTrigger>
                      <SelectValue placeholder="Select category" />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    <SelectItem value="bug">Bug Report</SelectItem>
                    <SelectItem value="feature">Feature Request</SelectItem>
                    <SelectItem value="question">Question</SelectItem>
                    <SelectItem value="other">Other</SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="priority"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Priority</FormLabel>
                <Select onValueChange={field.onChange} defaultValue={field.value}>
                  <FormControl>
                    <SelectTrigger>
                      <SelectValue />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    <SelectItem value="low">Low</SelectItem>
                    <SelectItem value="medium">Medium</SelectItem>
                    <SelectItem value="high">High</SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>
 
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Message</FormLabel>
              <FormControl>
                <Textarea placeholder="Describe your feedback..." rows={4} {...field} />
              </FormControl>
              <FormDescription>Minimum 10 characters.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="subscribe"
          render={({ field }) => (
            <FormItem className="flex items-start space-x-3 space-y-0">
              <FormControl>
                <Checkbox checked={field.value} onCheckedChange={field.onChange} />
              </FormControl>
              <div className="space-y-1 leading-none">
                <FormLabel>Email updates</FormLabel>
                <FormDescription>Get notified about the status of your feedback.</FormDescription>
              </div>
            </FormItem>
          )}
        />
 
        <Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
          {form.formState.isSubmitting ? "Sending..." : "Send Feedback"}
        </Button>
      </form>
    </Form>
  );
}

What this demonstrates:

  • shadcn Form with multiple input types (Input, Textarea, Select, Checkbox)
  • Grid layout for side-by-side fields
  • FormDescription for helper text
  • FormMessage auto-displays Zod validation errors
  • Select integration via onValueChange / defaultValue

Deep Dive

How It Works

  • <Form> is a context provider that passes the useForm return value to all children
  • <FormField> is a wrapper around RHF's <Controller> — it provides field, fieldState, and formState
  • <FormItem> establishes a context with a generated id for label-input association
  • <FormLabel> renders a <label> with the correct htmlFor and error styling
  • <FormControl> passes aria-invalid, aria-describedby, and the id to the input
  • <FormMessage> reads the error from the field context and renders it with role="alert"
  • <FormDescription> renders helper text linked via aria-describedby

Variations

Radio group:

import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
 
<FormField
  control={form.control}
  name="plan"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Plan</FormLabel>
      <FormControl>
        <RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="flex gap-4">
          <FormItem className="flex items-center space-x-2 space-y-0">
            <FormControl><RadioGroupItem value="free" /></FormControl>
            <FormLabel className="font-normal">Free</FormLabel>
          </FormItem>
          <FormItem className="flex items-center space-x-2 space-y-0">
            <FormControl><RadioGroupItem value="pro" /></FormControl>
            <FormLabel className="font-normal">Pro</FormLabel>
          </FormItem>
        </RadioGroup>
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

Date picker:

import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
 
<FormField
  control={form.control}
  name="date"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Date</FormLabel>
      <Popover>
        <PopoverTrigger asChild>
          <FormControl>
            <Button variant="outline">
              {field.value ? format(field.value, "PPP") : "Pick a date"}
            </Button>
          </FormControl>
        </PopoverTrigger>
        <PopoverContent>
          <Calendar mode="single" selected={field.value} onSelect={field.onChange} />
        </PopoverContent>
      </Popover>
      <FormMessage />
    </FormItem>
  )}
/>

TypeScript Notes

// FormField is generic — name is type-checked against the form schema
<FormField
  control={form.control}
  name="typo" // TS error: "typo" is not in FeedbackData
  render={({ field }) => /* ... */}
/>
 
// field.value is typed per field
<FormField
  name="category"
  render={({ field }) => {
    field.value; // "bug" | "feature" | "question" | "other"
  }}
/>

Gotchas

  • Select needs onValueChange, not onChange — Radix Select does not use native change events. Fix: Wire field.onChange to onValueChange and field.value to defaultValue.

  • Checkbox returns boolean, not string — Use field.value (boolean) with checked, and field.onChange with onCheckedChange. Do not use {...field} spread directly.

  • FormControl must wrap exactly one input — It clones aria-* and id props onto its single child. Wrapping multiple elements breaks the association. Fix: Wrap only the input element.

  • Missing defaultValues — If defaultValues is incomplete, shadcn Select shows a blank trigger instead of the placeholder. Fix: Provide complete defaultValues or use defaultValue on Select.

Alternatives

AlternativeUse WhenDon't Use When
Plain RHF + registerYou use native HTML inputs without a component libraryYou use shadcn/ui components
Formik + MUIYou are in a Material UI codebaseYou use Tailwind / shadcn
Mantine formYou use the Mantine component libraryYou use shadcn
Custom form primitivesYou need a fully custom design systemshadcn covers your needs

FAQs

What does the shadcn Form component do under the hood?

<Form> is a context provider that passes the useForm return value to all children. Child components like <FormField>, <FormLabel>, <FormControl>, and <FormMessage> read from this context to wire up IDs, aria attributes, and error display.

How does FormField relate to react-hook-form's Controller?

<FormField> is a wrapper around RHF's <Controller>. Its render prop provides field, fieldState, and formState, just like Controller does.

What accessibility features does FormControl provide automatically?
  • Sets a generated id on the input for label association
  • Adds aria-invalid when the field has an error
  • Adds aria-describedby linking to the description and error message elements
How do you wire a shadcn Select component inside FormField?
<Select onValueChange={field.onChange} defaultValue={field.value}>
  <FormControl>
    <SelectTrigger>
      <SelectValue placeholder="Pick one" />
    </SelectTrigger>
  </FormControl>
  <SelectContent>
    <SelectItem value="a">Option A</SelectItem>
  </SelectContent>
</Select>

Use onValueChange, not onChange, because Radix Select does not use native change events.

How do you use a Checkbox with shadcn Form?
<FormControl>
  <Checkbox
    checked={field.value}
    onCheckedChange={field.onChange}
  />
</FormControl>

Do not spread {...field} directly on Checkbox since it expects checked (boolean), not value.

What is the purpose of FormDescription?

It renders helper text below the input and is automatically linked via aria-describedby so screen readers announce it when the input is focused.

How does FormMessage display validation errors?

<FormMessage> reads the error from the field context (provided by FormField) and renders the error message with role="alert". No manual error checking is needed.

Gotcha: Why does a shadcn Select show a blank trigger instead of the placeholder?

This happens when defaultValues in useForm is incomplete or missing for that field. Provide complete defaultValues for all fields, or set defaultValue directly on the Select component.

Gotcha: What breaks if you wrap multiple elements inside FormControl?

FormControl clones aria-* and id props onto its single child. Wrapping multiple elements breaks the label-input association and accessibility attributes. Always wrap exactly one input element.

How do you add a RadioGroup inside a shadcn Form?
<RadioGroup
  onValueChange={field.onChange}
  defaultValue={field.value}
>
  <FormItem className="flex items-center space-x-2">
    <FormControl><RadioGroupItem value="free" /></FormControl>
    <FormLabel>Free</FormLabel>
  </FormItem>
</RadioGroup>
TypeScript: Is the name prop on FormField type-checked?

Yes. FormField is generic and checks name against the form schema type. Passing a name that does not exist in the schema produces a TypeScript error.

TypeScript: Is field.value typed per field inside the render prop?

Yes. For an enum field like category: z.enum(["bug", "feature"]), field.value is typed as "bug" | "feature", not string.

Can you use shadcn Form with a Date Picker?

Yes. Use a Popover with a Calendar inside FormField. Wire field.value to selected and field.onChange to onSelect on the Calendar component.