React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

shadcnformreact-hook-formzodvalidation

Form

shadcn Form component — react-hook-form integration with accessible labels, descriptions, and error messages.

Recipe

Quick-reference recipe card — copy-paste ready.

npx shadcn@latest add form input button label
"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, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
 
const Schema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
});
 
export function BasicForm() {
  const form = useForm<z.infer<typeof Schema>>({
    resolver: zodResolver(Schema),
    defaultValues: { email: "", name: "" },
  });
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
        <FormField control={form.control} name="name" render={({ field }) => (
          <FormItem>
            <FormLabel>Name</FormLabel>
            <FormControl><Input {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <FormField control={form.control} name="email" render={({ field }) => (
          <FormItem>
            <FormLabel>Email</FormLabel>
            <FormControl><Input type="email" {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

When to reach for this: When you use shadcn/ui and need forms with validation — the Form component automates ARIA attributes, error display, and label association.

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 { Switch } from "@/components/ui/switch";
import {
  Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
 
const EventSchema = z.object({
  title: z.string().min(1, "Title is required").max(100),
  description: z.string().max(500).optional(),
  date: z.string().min(1, "Date is required"),
  time: z.string().min(1, "Time is required"),
  location: z.string().min(1, "Location is required"),
  type: z.enum(["conference", "meetup", "workshop", "webinar"]),
  maxAttendees: z.coerce.number().int().positive().max(10000),
  isPublic: z.boolean().default(true),
  requiresRegistration: z.boolean().default(false),
});
 
type EventData = z.infer<typeof EventSchema>;
 
export function CreateEventForm() {
  const form = useForm<EventData>({
    resolver: zodResolver(EventSchema),
    defaultValues: {
      title: "",
      description: "",
      date: "",
      time: "",
      location: "",
      type: "meetup",
      maxAttendees: 100,
      isPublic: true,
      requiresRegistration: false,
    },
  });
 
  async function onSubmit(data: EventData) {
    await new Promise((r) => setTimeout(r, 1000));
    toast.success("Event created!", { description: data.title });
    form.reset();
  }
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg space-y-6">
        <FormField control={form.control} name="title" render={({ field }) => (
          <FormItem>
            <FormLabel>Event Title</FormLabel>
            <FormControl><Input placeholder="React Conf 2025" {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
 
        <FormField control={form.control} name="description" render={({ field }) => (
          <FormItem>
            <FormLabel>Description</FormLabel>
            <FormControl><Textarea placeholder="What is this event about?" rows={3} {...field} /></FormControl>
            <FormDescription>Optional. Max 500 characters.</FormDescription>
            <FormMessage />
          </FormItem>
        )} />
 
        <div className="grid grid-cols-2 gap-4">
          <FormField control={form.control} name="date" render={({ field }) => (
            <FormItem>
              <FormLabel>Date</FormLabel>
              <FormControl><Input type="date" {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )} />
 
          <FormField control={form.control} name="time" render={({ field }) => (
            <FormItem>
              <FormLabel>Time</FormLabel>
              <FormControl><Input type="time" {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )} />
        </div>
 
        <FormField control={form.control} name="location" render={({ field }) => (
          <FormItem>
            <FormLabel>Location</FormLabel>
            <FormControl><Input placeholder="City, venue, or URL" {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
 
        <div className="grid grid-cols-2 gap-4">
          <FormField control={form.control} name="type" render={({ field }) => (
            <FormItem>
              <FormLabel>Event Type</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="conference">Conference</SelectItem>
                  <SelectItem value="meetup">Meetup</SelectItem>
                  <SelectItem value="workshop">Workshop</SelectItem>
                  <SelectItem value="webinar">Webinar</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )} />
 
          <FormField control={form.control} name="maxAttendees" render={({ field }) => (
            <FormItem>
              <FormLabel>Max Attendees</FormLabel>
              <FormControl><Input type="number" {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )} />
        </div>
 
        <FormField control={form.control} name="isPublic" render={({ field }) => (
          <FormItem className="flex items-center justify-between rounded-lg border p-3">
            <div>
              <FormLabel>Public Event</FormLabel>
              <FormDescription>Anyone can discover this event.</FormDescription>
            </div>
            <FormControl>
              <Switch checked={field.value} onCheckedChange={field.onChange} />
            </FormControl>
          </FormItem>
        )} />
 
        <FormField control={form.control} name="requiresRegistration" render={({ field }) => (
          <FormItem className="flex items-center justify-between rounded-lg border p-3">
            <div>
              <FormLabel>Require Registration</FormLabel>
              <FormDescription>Attendees must register in advance.</FormDescription>
            </div>
            <FormControl>
              <Switch checked={field.value} onCheckedChange={field.onChange} />
            </FormControl>
          </FormItem>
        )} />
 
        <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "Creating..." : "Create Event"}
        </Button>
      </form>
    </Form>
  );
}

What this demonstrates:

  • Full form with multiple input types: text, textarea, date, time, number, select, switch
  • Grid layout for side-by-side fields
  • Switch toggle with label and description in a bordered container
  • Toast notification on successful submission
  • Proper FormControl wrapping for each input type

Deep Dive

How It Works

  • <Form> is a React context provider that wraps FormProvider from react-hook-form
  • <FormField> wraps RHF's <Controller> — it provides field, fieldState, and formState to the render function
  • <FormItem> generates a unique id and shares it via context to child components
  • <FormLabel> renders a <label> with htmlFor automatically set to the field's id
  • <FormControl> clones id, aria-invalid, and aria-describedby onto its child input
  • <FormMessage> reads the error from fieldState and renders it with the correct id for aria-describedby
  • <FormDescription> renders helper text with an id also linked via aria-describedby

Variations

Reusable field wrapper:

function TextField({
  form,
  name,
  label,
  placeholder,
  description,
}: {
  form: UseFormReturn<any>;
  name: string;
  label: string;
  placeholder?: string;
  description?: string;
}) {
  return (
    <FormField
      control={form.control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <Input placeholder={placeholder} {...field} />
          </FormControl>
          {description && <FormDescription>{description}</FormDescription>}
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

Form in a dialog:

<Dialog>
  <DialogTrigger asChild><Button>Edit</Button></DialogTrigger>
  <DialogContent>
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <DialogHeader>
          <DialogTitle>Edit Item</DialogTitle>
        </DialogHeader>
        {/* FormFields here */}
        <DialogFooter>
          <Button type="submit">Save</Button>
        </DialogFooter>
      </form>
    </Form>
  </DialogContent>
</Dialog>

TypeScript Notes

// FormField is generic — catches wrong field names
<FormField
  control={form.control}
  name="nonexistent"  // TS error if not in schema
  render={({ field }) => <Input {...field} />}
/>
 
// field.value is typed per field
<FormField name="type" render={({ field }) => {
  field.value; // "conference" | "meetup" | "workshop" | "webinar"
}} />
 
// UseFormReturn type for passing form to children
import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }

Gotchas

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

  • Switch needs checked + onCheckedChange — Do not spread {...field}. Fix: Use checked={field.value} and onCheckedChange={field.onChange}.

  • FormControl must wrap one element — It clones ARIA props onto its single child. Multiple children breaks it. Fix: Wrap only the input element in FormControl.

  • Number inputs return strings<Input type="number" {...field}> returns a string. Fix: Use z.coerce.number() in the schema to handle coercion.

  • FormMessage renders nothing when validFormMessage only renders when there is an error. No extra conditional needed.

Alternatives

AlternativeUse WhenDon't Use When
Plain RHF + registerYou use native inputs without a component libraryYou use shadcn and want consistent accessible forms
Formik + YupYou are in a Formik-based codebaseStarting fresh with modern React
Native form + Server ActionsYou want minimal client JSYou need instant field-level validation
AutoForm (shadcn extension)You want forms auto-generated from Zod schemasYou need custom layout and behavior

FAQs

What three libraries work together in the shadcn Form component?
  • react-hook-form for form state management and validation
  • zod for schema-based validation
  • @hookform/resolvers/zod to bridge Zod schemas into react-hook-form
What does each shadcn Form sub-component do?
  • Form — wraps RHF's FormProvider, shares form context
  • FormField — wraps RHF's Controller, provides field and fieldState
  • FormItem — generates a unique id shared by label, input, and message
  • FormLabel — renders <label> with auto-set htmlFor
  • FormControl — clones id, aria-invalid, aria-describedby onto child input
  • FormMessage — renders error message linked via aria-describedby
How do you integrate a Select component with the Form?
<FormField name="type" render={({ field }) => (
  <FormItem>
    <FormLabel>Type</FormLabel>
    <Select onValueChange={field.onChange} defaultValue={field.value}>
      <FormControl>
        <SelectTrigger><SelectValue /></SelectTrigger>
      </FormControl>
      <SelectContent>
        <SelectItem value="meetup">Meetup</SelectItem>
      </SelectContent>
    </Select>
    <FormMessage />
  </FormItem>
)} />
Gotcha: Why does spreading {...field} on a Select or Switch not work?
  • Radix Select uses onValueChange not onChange, and defaultValue not value
  • Switch uses checked and onCheckedChange instead of standard input props
  • Fix: wire the specific props manually instead of spreading {...field}
How do you handle number inputs that return strings?
  • <Input type="number" {...field}> always returns a string value
  • Fix: use z.coerce.number() in the Zod schema to coerce strings to numbers during validation
How do you build a reusable text field wrapper?
function TextField({ form, name, label, placeholder }: {
  form: UseFormReturn<any>;
  name: string;
  label: string;
  placeholder?: string;
}) {
  return (
    <FormField control={form.control} name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl><Input placeholder={placeholder} {...field} /></FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}
How does FormField catch incorrect field names at compile time?
  • FormField is generic and typed against the Zod schema
  • Passing name="nonexistent" produces a TypeScript error if the field is not in the schema
  • field.value is also typed per field (e.g., a union type for enums)
Gotcha: Why must FormControl wrap exactly one child element?
  • FormControl clones ARIA attributes (id, aria-invalid, aria-describedby) onto its single child
  • Multiple children breaks the cloning mechanism
  • Fix: wrap only the input element, not additional elements like icons
How do you embed a form inside a Dialog?
<DialogContent>
  <Form {...form}>
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <DialogHeader><DialogTitle>Edit</DialogTitle></DialogHeader>
      {/* FormFields here */}
      <DialogFooter><Button type="submit">Save</Button></DialogFooter>
    </form>
  </Form>
</DialogContent>
How do you use a Switch toggle with shadcn Form?
<FormField name="isPublic" render={({ field }) => (
  <FormItem className="flex items-center justify-between rounded-lg border p-3">
    <div>
      <FormLabel>Public</FormLabel>
      <FormDescription>Visible to everyone.</FormDescription>
    </div>
    <FormControl>
      <Switch checked={field.value} onCheckedChange={field.onChange} />
    </FormControl>
  </FormItem>
)} />
What TypeScript type do you use when passing the form object to child components?
import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }
Does FormMessage render anything when the field is valid?
  • No. FormMessage only renders when fieldState contains an error
  • No conditional wrapping is needed around it