Form
Componente Form de shadcn - integración con react-hook-form con labels, descripciones y mensajes de error accesibles.
Busca en todas las páginas de la documentación
Componente Form de shadcn - integración con react-hook-form con labels, descripciones y mensajes de error accesibles.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
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>Nombre</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Correo electrónico</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit">Enviar</Button>
</form>
</Form>
);
}Cuándo usarlo: Cuando uses shadcn/ui y necesites formularios con validación - el componente Form automatiza los atributos ARIA, la visualización de errores y la asociación de labels.
"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, "El título es obligatorio").max(100),
description: z.string().max(500).optional(),
date: z.string().min(1, "La fecha es obligatoria"),
time: z.string().min(1, "La hora es obligatoria"),
location: z.string().min(1, "La ubicación es obligatoria"),
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("¡Evento creado!", { 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>Título del evento</FormLabel>
<FormControl><Input placeholder="React Conf 2025" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem>
<FormLabel>Descripción</FormLabel>
<FormControl><Textarea placeholder="¿De qué trata este evento?" rows={3} {...field} /></FormControl>
<FormDescription>Opcional. Máximo 500 caracteres.</FormDescription>
<FormMessage />
</FormItem>
)} />
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="date" render={({ field }) => (
<FormItem>
<FormLabel>Fecha</FormLabel>
<FormControl><Input type="date" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="time" render={({ field }) => (
<FormItem>
<FormLabel>Hora</FormLabel>
<FormControl><Input type="time" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="location" render={({ field }) => (
<FormItem>
<FormLabel>Ubicación</FormLabel>
<FormControl><Input placeholder="Ciudad, lugar o URL" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="type" render={({ field }) => (
<FormItem>
<FormLabel>Tipo de evento</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="conference">Conferencia</SelectItem>
<SelectItem value="meetup">Meetup</SelectItem>
<SelectItem value="workshop">Taller</SelectItem>
<SelectItem value="webinar">Webinar</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="maxAttendees" render={({ field }) => (
<FormItem>
<FormLabel>Asistentes máximos</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>Evento público</FormLabel>
<FormDescription>Cualquiera puede descubrir este evento.</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>Requerir registro</FormLabel>
<FormDescription>Los asistentes deben registrarse con anticipación.</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 ? "Creando..." : "Crear evento"}
</Button>
</form>
</Form>
);
}Lo que esto demuestra:
FormControl para cada tipo de input<Form> es un proveedor de contexto de React que envuelve FormProvider de react-hook-form<FormField> envuelve el <Controller> de RHF - proporciona field, fieldState y formState a la función render<FormItem> genera un id único y lo comparte mediante contexto con los componentes hijo<FormLabel> renderiza un <label> con htmlFor establecido automáticamente al id del campo<FormControl> clona id, aria-invalid y aria-describedby en su input hijo<FormMessage> lee el error de fieldState y lo renderiza con el id correcto para aria-describedby<FormDescription> renderiza texto auxiliar con un id también enlazado mediante aria-describedbyWrapper reutilizable de campo:
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>
)}
/>
);
}Formulario en un diálogo:
<Dialog>
<DialogTrigger asChild><Button>Editar</Button></DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Editar elemento</DialogTitle>
</DialogHeader>
{/* FormFields aquí */}
<DialogFooter>
<Button type="submit">Guardar</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>// FormField es genérico - detecta nombres de campo incorrectos
<FormField
control={form.control}
name="nonexistent" // Error de TS si no está en el esquema
render={({ field }) => <Input {...field} />}
/>
// field.value está tipado por campo
<FormField name="type" render={({ field }) => {
field.value; // "conference" | "meetup" | "workshop" | "webinar"
}} />
// Tipo UseFormReturn para pasar el formulario a componentes hijo
import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }Select necesita onValueChange, no {...field} - Radix Select no usa eventos nativos. Solución: Conecta onValueChange={field.onChange} y defaultValue={field.value}.
Switch necesita checked + onCheckedChange - No hagas spread de {...field}. Solución: Usa checked={field.value} y onCheckedChange={field.onChange}.
FormControl debe envolver un solo elemento - Clona props ARIA en su único child. Varios children lo rompen. Solución: Envuelve solo el elemento input en FormControl.
Los inputs numéricos devuelven strings - <Input type="number" {...field}> devuelve un string. Solución: Usa z.coerce.number() en el esquema para manejar la coerción.
FormMessage no renderiza nada cuando es válido - FormMessage solo renderiza cuando hay un error. No necesitas una condicional extra.
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| RHF plano + register | Usas inputs nativos sin librería de componentes | Usas shadcn y quieres formularios accesibles consistentes |
| Formik + Yup | Estás en una base de código basada en Formik | Empiezas de cero con React moderno |
| Formulario nativo + Server Actions | Quieres JS de cliente mínimo | Necesitas validación instantánea a nivel de campo |
| AutoForm (extensión shadcn) | Quieres formularios auto-generados desde esquemas Zod | Necesitas layout y comportamiento personalizados |
react-hook-form para gestión de estado del formulario y validaciónzod para validación basada en esquemas@hookform/resolvers/zod para conectar esquemas Zod con react-hook-formForm - envuelve el FormProvider de RHF, comparte el contexto del formularioFormField - envuelve el Controller de RHF, proporciona field y fieldStateFormItem - genera un id único compartido por label, input y mensajeFormLabel - renderiza <label> con htmlFor establecido automáticamenteFormControl - clona id, aria-invalid, aria-describedby en el input hijoFormMessage - renderiza el mensaje de error enlazado mediante aria-describedby<FormField name="type" render={({ field }) => (
<FormItem>
<FormLabel>Tipo</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="meetup">Meetup</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />onValueChange, no onChange, y defaultValue, no valuechecked y onCheckedChange en lugar de props estándar de input{...field}<Input type="number" {...field}> siempre devuelve un valor stringz.coerce.number() en el esquema Zod para convertir strings a números durante la validaciónfunction 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>
)}
/>
);
}FormField es genérico y está tipado contra el esquema Zodname="nonexistent" produce un error de TypeScript si el campo no está en el esquemafield.value también está tipado por campo (p. ej., un tipo unión para enums)FormControl clona atributos ARIA (id, aria-invalid, aria-describedby) en su único child<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader><DialogTitle>Editar</DialogTitle></DialogHeader>
{/* FormFields aquí */}
<DialogFooter><Button type="submit">Guardar</Button></DialogFooter>
</form>
</Form>
</DialogContent><FormField name="isPublic" render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<div>
<FormLabel>Público</FormLabel>
<FormDescription>Visible para todos.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)} />import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }FormMessage solo renderiza cuando fieldState contiene un errorRevisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥