Form
Componente Form de shadcn — integración con react-hook-form con labels, descripciones y mensajes de error accesibles.
Receta
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.
Ejemplo funcional
"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:
- Formulario completo con múltiples tipos de input: texto, textarea, fecha, hora, número, select, switch
- Diseño en grid para campos lado a lado
- Switch con label y descripción en un contenedor con borde
- Notificación toast al enviar correctamente
- Envoltorio correcto de
FormControlpara cada tipo de input
Análisis Profundo
Cómo Funciona
<Form>es un proveedor de contexto de React que envuelveFormProviderde react-hook-form<FormField>envuelve el<Controller>de RHF — proporcionafield,fieldStateyformStatea la función render<FormItem>genera unidúnico y lo comparte mediante contexto con los componentes hijo<FormLabel>renderiza un<label>conhtmlForestablecido automáticamente aliddel campo<FormControl>clonaid,aria-invalidyaria-describedbyen su input hijo<FormMessage>lee el error defieldStatey lo renderiza con elidcorrecto paraaria-describedby<FormDescription>renderiza texto auxiliar con unidtambién enlazado mediantearia-describedby
Variaciones
Wrapper 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>Notas de TypeScript
// 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> }) { ... }Errores comunes
-
Select necesita
onValueChange, no{...field}— Radix Select no usa eventos nativos. Solución: ConectaonValueChange={field.onChange}ydefaultValue={field.value}. -
Switch necesita
checked+onCheckedChange— No hagas spread de{...field}. Solución: Usachecked={field.value}yonCheckedChange={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: Usaz.coerce.number()en el esquema para manejar la coerción. -
FormMessage no renderiza nada cuando es válido —
FormMessagesolo renderiza cuando hay un error. No necesitas una condicional extra.
Alternativas
| 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 |
FAQs
¿Qué tres librerías trabajan juntas en el componente Form de shadcn?
react-hook-formpara gestión de estado del formulario y validaciónzodpara validación basada en esquemas@hookform/resolvers/zodpara conectar esquemas Zod con react-hook-form
¿Qué hace cada subcomponente del Form de shadcn?
Form— envuelve elFormProviderde RHF, comparte el contexto del formularioFormField— envuelve elControllerde RHF, proporcionafieldyfieldStateFormItem— genera unidúnico compartido por label, input y mensajeFormLabel— renderiza<label>conhtmlForestablecido automáticamenteFormControl— clonaid,aria-invalid,aria-describedbyen el input hijoFormMessage— renderiza el mensaje de error enlazado mediantearia-describedby
¿Cómo integras un componente Select con el Form?
<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>
)} />Gotcha: ¿Por qué hacer spread de {...field} en un Select o Switch no funciona?
- Radix Select usa
onValueChange, noonChange, ydefaultValue, novalue - Switch usa
checkedyonCheckedChangeen lugar de props estándar de input - Solución: conecta las props específicas manualmente en lugar de hacer spread de
{...field}
¿Cómo manejas inputs numéricos que devuelven strings?
<Input type="number" {...field}>siempre devuelve un valor string- Solución: usa
z.coerce.number()en el esquema Zod para convertir strings a números durante la validación
¿Cómo construyes un wrapper reutilizable de campo de texto?
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>
)}
/>
);
}¿Cómo detecta FormField nombres de campo incorrectos en tiempo de compilación?
FormFieldes genérico y está tipado contra el esquema Zod- Pasar
name="nonexistent"produce un error de TypeScript si el campo no está en el esquema field.valuetambién está tipado por campo (p. ej., un tipo unión para enums)
Gotcha: ¿Por qué FormControl debe envolver exactamente un elemento hijo?
FormControlclona atributos ARIA (id,aria-invalid,aria-describedby) en su único child- Varios children rompen el mecanismo de clonación
- Solución: envuelve solo el elemento input, no elementos adicionales como iconos
¿Cómo incrustas un formulario dentro de un Dialog?
<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>¿Cómo usas un Switch con el Form de shadcn?
<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>
)} />¿Qué tipo de TypeScript usas al pasar el objeto form a componentes hijo?
import type { UseFormReturn } from "react-hook-form";
function Subform({ form }: { form: UseFormReturn<EventData> }) { ... }¿FormMessage renderiza algo cuando el campo es válido?
- No.
FormMessagesolo renderiza cuandofieldStatecontiene un error - No se necesita envolverlo en una condicional
Relacionado
- Configuración de shadcn — instalación y temas
- Button — botones de envío
- Dialog — formularios en diálogos
- shadcn Form (sección forms) — patrones detallados de Form