shadcn Form
Construye formularios con el componente Form de shadcn - react-hook-form + Zod + componentes de UI accesibles, todo conectado.
Busca en todas las páginas de la documentación
Construye formularios con el componente Form de shadcn - react-hook-form + Zod + componentes de UI accesibles, todo conectado.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
"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, "Al menos 2 caracteres"),
email: z.string().email("Correo electrónico inválido"),
});
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>Nombre de usuario</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>Tu nombre público visible.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Correo electrónico</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Enviar</Button>
</form>
</Form>
);
}Cuándo usarlo: Cuando uses shadcn/ui y quieras campos de formulario consistentes y accesibles con visualización de errores integrada, etiquetas y descripciones.
"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, "El nombre es obligatorio"),
email: z.string().email("Correo electrónico inválido"),
category: z.enum(["bug", "feature", "question", "other"], {
required_error: "Selecciona una categoría",
}),
message: z.string().min(10, "Al menos 10 caracteres").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>Nombre</FormLabel>
<FormControl>
<Input placeholder="Tu nombre" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Correo electrónico</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>Categoría</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Selecciona una categoría" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bug">Reporte de Bug</SelectItem>
<SelectItem value="feature">Solicitud de Feature</SelectItem>
<SelectItem value="question">Pregunta</SelectItem>
<SelectItem value="other">Otro</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Prioridad</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">Baja</SelectItem>
<SelectItem value="medium">Media</SelectItem>
<SelectItem value="high">Alta</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Mensaje</FormLabel>
<FormControl>
<Textarea placeholder="Describe tu feedback..." rows={4} {...field} />
</FormControl>
<FormDescription>Mínimo 10 caracteres.</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>Actualizaciones por correo</FormLabel>
<FormDescription>Recibe notificaciones sobre el estado de tu feedback.</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
{form.formState.isSubmitting ? "Enviando..." : "Enviar Feedback"}
</Button>
</form>
</Form>
);
}Lo que esto demuestra:
onValueChange / defaultValue<Form> es un proveedor de contexto que pasa el valor de retorno useForm a todos los children<FormField> es un wrapper alrededor del <Controller> de RHF - proporciona field, fieldState, y formState<FormItem> establece un contexto con un id generado para la asociación label-input<FormLabel> renderiza un <label> con el htmlFor correcto y estilos de error<FormControl> pasa aria-invalid, aria-describedby, y el id al input<FormMessage> lee el error del contexto del campo y lo renderiza con role="alert"<FormDescription> renderiza texto auxiliar enlazado mediante aria-describedbyRadio 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">Gratuito</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>
)}
/>Selector de fecha:
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem>
<FormLabel>Fecha</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline">
{field.value ? format(field.value, "PPP") : "Selecciona una fecha"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent>
<Calendar mode="single" selected={field.value} onSelect={field.onChange} />
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>// FormField es genérico - name está type-checked contra el esquema del formulario
<FormField
control={form.control}
name="typo" // Error de TS: "typo" no está en FeedbackData
render={({ field }) => /* ... */}
/>
// field.value está tipado por campo
<FormField
name="category"
render={({ field }) => {
field.value; // "bug" | "feature" | "question" | "other"
}}
/>Select necesita onValueChange, no onChange - Radix Select no utiliza eventos de cambio nativos. Solución: Conecta field.onChange a onValueChange y field.value a defaultValue.
Checkbox retorna boolean, no string - Usa field.value (booleano) con checked, y field.onChange con onCheckedChange. No uses spread {...field} directamente.
FormControl debe envolver exactamente un input - Clona props aria-* e id en su único child. Envolver múltiples elementos rompe la asociación. Solución: Envuelve solo el elemento input.
Falta defaultValues - Si defaultValues está incompleto, shadcn Select muestra un trigger en blanco en lugar del placeholder. Solución: Proporciona defaultValues completos o usa defaultValue en Select.
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
| Plain RHF + register | Usas inputs HTML nativos sin librería de componentes | Usas componentes shadcn/ui |
| Formik + MUI | Estás en una base de código Material UI | Usas Tailwind / shadcn |
| Mantine form | Usas la librería de componentes Mantine | Usas shadcn |
| Primitivos de formulario personalizados | Necesitas un sistema de diseño completamente personalizado | shadcn cubre tus necesidades |
<Form> es un proveedor de contexto que pasa el valor de retorno useForm a todos los children. Los componentes child como <FormField>, <FormLabel>, <FormControl>, y <FormMessage> leen de este contexto para conectar IDs, atributos aria, y visualización de errores.
<FormField> es un wrapper alrededor del <Controller> de RHF. Su prop render proporciona field, fieldState, y formState, igual que Controller.
id generado en el input para asociación de etiquetaaria-invalid cuando el campo tiene un erroraria-describedby enlazando a los elementos de descripción y mensaje de error<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Elige uno" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="a">Opción A</SelectItem>
</SelectContent>
</Select>Usa onValueChange, no onChange, porque Radix Select no utiliza eventos de cambio nativos.
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>No hagas spread de {...field} directamente en Checkbox ya que espera checked (booleano), no value.
Renderiza texto auxiliar debajo del input y está automáticamente enlazado mediante aria-describedby para que los lectores de pantalla lo anuncien cuando el input tiene foco.
<FormMessage> lee el error del contexto del campo (proporcionado por FormField) y renderiza el mensaje de error con role="alert". No se necesitan verificaciones de error manuales.
Esto sucede cuando defaultValues en useForm está incompleto o falta para ese campo. Proporciona defaultValues completos para todos los campos, o establece defaultValue directamente en el componente Select.
FormControl clona props aria-* e id en su único child. Envolver múltiples elementos rompe la asociación label-input y los atributos de accesibilidad. Siempre envuelve exactamente un elemento input.
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormItem className="flex items-center space-x-2">
<FormControl><RadioGroupItem value="free" /></FormControl>
<FormLabel>Gratuito</FormLabel>
</FormItem>
</RadioGroup>Sí. FormField es genérico y verifica name contra el tipo de esquema del formulario. Pasar un name que no existe en el esquema produce un error de TypeScript.
Sí. Para un campo enum como category: z.enum(["bug", "feature"]), field.value está tipado como "bug" | "feature", no string.
Sí. Usa un Popover con un Calendar dentro de FormField. Conecta field.value a selected y field.onChange a onSelect en el componente Calendar.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥