shadcn Form
Construye formularios con el componente Form de shadcn — react-hook-form + Zod + componentes de UI accesibles, todo conectado.
Receta
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.
Ejemplo Completo
"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:
- shadcn Form con múltiples tipos de input (Input, Textarea, Select, Checkbox)
- Diseño en grid para campos lado a lado
- FormDescription para texto auxiliar
- FormMessage muestra automáticamente errores de validación Zod
- Integración de Select mediante
onValueChange/defaultValue
Análisis Profundo
Cómo Funciona
<Form>es un proveedor de contexto que pasa el valor de retornouseForma todos los children<FormField>es un wrapper alrededor del<Controller>de RHF — proporcionafield,fieldState, yformState<FormItem>establece un contexto con unidgenerado para la asociación label-input<FormLabel>renderiza un<label>con elhtmlForcorrecto y estilos de error<FormControl>pasaaria-invalid,aria-describedby, y elidal input<FormMessage>lee el error del contexto del campo y lo renderiza conrole="alert"<FormDescription>renderiza texto auxiliar enlazado mediantearia-describedby
Variaciones
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">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>
)}
/>Notas de TypeScript
// 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"
}}
/>Gotchas
-
Select necesita
onValueChange, noonChange— Radix Select no utiliza eventos de cambio nativos. Solución: Conectafield.onChangeaonValueChangeyfield.valueadefaultValue. -
Checkbox retorna
boolean, no string — Usafield.value(booleano) conchecked, yfield.onChangecononCheckedChange. No uses spread{...field}directamente. -
FormControl debe envolver exactamente un input — Clona props
aria-*eiden su único child. Envolver múltiples elementos rompe la asociación. Solución: Envuelve solo el elemento input. -
Falta defaultValues — Si
defaultValuesestá incompleto, shadcn Select muestra un trigger en blanco en lugar del placeholder. Solución: ProporcionadefaultValuescompletos o usadefaultValueen Select.
Alternativas
| 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 |
FAQs
¿Qué hace el componente shadcn Form bajo el capó?
<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.
¿Cómo se relaciona FormField con el Controller de react-hook-form?
<FormField> es un wrapper alrededor del <Controller> de RHF. Su prop render proporciona field, fieldState, y formState, igual que Controller.
¿Qué características de accesibilidad proporciona FormControl automáticamente?
- Establece un
idgenerado en el input para asociación de etiqueta - Agrega
aria-invalidcuando el campo tiene un error - Agrega
aria-describedbyenlazando a los elementos de descripción y mensaje de error
¿Cómo conectas un componente shadcn Select dentro de FormField?
<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.
¿Cómo usas un Checkbox con shadcn Form?
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>No hagas spread de {...field} directamente en Checkbox ya que espera checked (booleano), no value.
¿Cuál es el propósito de FormDescription?
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.
¿Cómo muestra FormMessage los errores de validación?
<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.
Gotcha: ¿Por qué un shadcn Select muestra un trigger en blanco en lugar del placeholder?
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.
Gotcha: ¿Qué se rompe si envuelves múltiples elementos dentro de FormControl?
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.
¿Cómo añades un RadioGroup dentro de un shadcn Form?
<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>TypeScript: ¿Está type-checked la prop name en FormField?
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.
TypeScript: ¿Está field.value tipado por campo dentro del prop render?
Sí. Para un campo enum como category: z.enum(["bug", "feature"]), field.value está tipado como "bug" | "feature", no string.
¿Puedes usar shadcn Form con un Date Picker?
Sí. Usa un Popover con un Calendar dentro de FormField. Conecta field.value a selected y field.onChange a onSelect en el componente Calendar.
Relacionado
- RHF + Zod — configuración del resolver
- React Hook Form — conceptos centrales de RHF
- Form Error Display — patrones de error
- shadcn Form component — detalles del componente shadcn