Patrones de composición en shadcn/ui: mejores prácticas para que varios componentes trabajen juntos

Abre el código de la página de gestión de usuarios.
Un DataTable muestra la lista de usuarios, cada fila tiene un DropdownMenu de acciones, haces clic en «Editar» y se abre un Dialog con un Form dentro. Suena a una función sencilla, ¿verdad?
Pero el código es así: el estado pasa de mano en mano, el prop drilling llega al quinto nivel, el estado open del Dialog está en el componente padre, los datos del Form en el hijo, y tras el envío la callback tiene que volver al padre para actualizar el DataTable…
En su momento dudé bastante de shadcn/ui. «Un componente suelto se siente genial; ¿por qué al combinarlos todo se vuelve un caos?»
Luego leí la documentación de diseño de shadcn/ui y entendí que el problema no era la biblioteca, sino no conocer los patrones de composición. La filosofía central de shadcn/ui es «composición antes que herencia»: cada componente tiene una interfaz uniforme y predecible. Si no entiendes esa filosofía, al combinarlos todo se enreda.
Hoy repaso los errores que cometí y las mejores prácticas de composición que aprendí después.
Primero, entender la filosofía de diseño de shadcn/ui
Antes de entrar en composiciones concretas, hay que captar la lógica de diseño de shadcn/ui. Si no, te preguntarás por qué otros combinan componentes React con claridad y tú acabas con código espagueti.
La mayor diferencia frente a las bibliotecas UI tradicionales: shadcn/ui no es un paquete npm. No verás @shadcn/ui en el package.json. Copias el código de los componentes directamente en tu proyecto.
Suena primitivo, ¿verdad? Pero esa es la filosofía:
Open Code: el código de los componentes es completamente abierto; lo modificas como quieras sin miedo a conflictos de versión. Si no te gusta un estilo del Button, cambias el código fuente sin esperar una nueva versión oficial.
Composition: todos los componentes usan una interfaz componible uniforme. ¿Qué significa? La estructura de cada componente es predecible. Card siempre es <Card><CardHeader><CardTitle><CardContent>, Dialog siempre es <Dialog><DialogContent><DialogHeader><DialogTitle>.
La ventaja: al combinar varios componentes, sabes qué anidar y qué poner en paralelo. Sin contradicciones del tipo «este va dentro de aquel, pero aquel exige estar fuera».
Composición básica: Dialog + Form
El escenario más habitual: un modal con un formulario dentro.
El usuario hace clic en «Editar», se abre un Dialog con un Form, rellena y envía, y el Dialog se cierra. Parece simple, pero al principio mezclé el estado del Dialog y del Form.
Ejemplo incorrecto
// ❌ La versión en la que tropecé
function EditUserDialog() {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState{}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => fetchUserData()}>Editar</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={(e) => {
e.preventDefault()
submitForm(formData)
setOpen(false)
}}>
<Input
value={formData.username}
onChange={(e) => setFormData({...formData, username: e.target.value})}
/>
<Button type="submit">Guardar</Button>
</form>
</DialogContent>
</Dialog>
)
}
¿Cuál es el problema? El estado open del Dialog y los datos del Form viven en el mismo componente; gestiono el formulario a mano sin React Hook Form, así que la validación y los errores se vuelven un lío.
Enfoque correcto
El Form de shadcn/ui se basa en React Hook Form + Zod. Con esta combinación el código queda mucho más limpio:
// ✅ Composición correcta
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
// 1. Define el Schema (fuera del componente)
const userSchema = z.object({
username: z.string().min(3, "El nombre de usuario debe tener al menos 3 caracteres"),
email: z.string().email("Formato de correo electrónico incorrecto")
})
function EditUserDialog({ user, onSubmit }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(userSchema),
defaultValues: user // pasa directamente los datos del usuario
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Editar</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar información del usuario</DialogTitle>
</DialogHeader>
{/* Form con el componente shadcn */}
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => {
onSubmit(data) // envía los datos
setOpen(false) // cierra el Dialog
})}>
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre de usuario</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* errores automáticos */}
</FormItem>
)}
/>
<Button type="submit">Guardar</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
Puntos clave:
- Dialog es el contenedor, Form es el contenido: Dialog solo gestiona abrir/cerrar, Form gestiona datos/validación/envío — responsabilidades separadas.
- Form con React Hook Form + Zod: no gestiones el estado del formulario a mano;
form.handleSubmitse encarga de la validación y el envío. - FormMessage muestra los errores automáticamente: sin lógica personalizada; si Zod falla, el error aparece solo.
Así los estados quedan claros: open del Dialog en el padre, los datos del Form dentro de Form (gestionados por React Hook Form).
DataTable + DropdownMenu: acciones por fila
Otro escenario frecuente: cada fila tiene un menú de acciones; al hacer clic en «Editar» se abre un Dialog.
Mi error: no sabía cómo pasar los datos de la fila al Dialog. En las columnas del DataTable tienes row.original, pero el Dialog está fuera de la tabla — ¿cómo conectarlos?
Ejemplo incorrecto
// ❌ Mi primera solución: Dialog anidado en la cell
const columns = [
{
id: "actions",
cell: { row } => (
<Dialog>
<DialogTrigger asChild>
<Button>Editar</Button>
</DialogTrigger>
<DialogContent>
{/* Problema: cada render de la cell crea una instancia de Dialog */}
<EditForm user={row.original} />
</DialogContent>
</Dialog>
)
}
]
El problema: cada fila crea un Dialog. Con 100 filas tienes 100 Dialog — rendimiento pésimo y estado difícil de gestionar.
Enfoque correcto
Un Dialog global, con el estado gestionado por un Hook:
// 1. Hook para el estado del Dialog
const useEditDialog = () => {
const [open, setOpen] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const openEdit = (user) => {
setEditingUser(user)
setOpen(true)
}
const closeEdit = () => {
setOpen(false)
setEditingUser(null)
}
return { open, editingUser, openEdit, closeEdit }
}
// 2. En las columnas solo el botón disparador
function UserDataTable({ users }) {
const { open, editingUser, openEdit, closeEdit } = useEditDialog()
const columns = [
{
id: "actions",
cell: { row } => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => openEdit(row.original)}>
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
]
return (
<>
<DataTable columns={columns} data={users} />
{/* Un solo Dialog global */}
<Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
<DialogContent>
<EditUserForm
user={editingUser}
onSubmit={(data) => {
updateUser(data)
closeEdit()
refreshTable() // actualiza los datos de la tabla
}}
/>
</DialogContent>
</Dialog>
</>
)
}
Puntos clave:
- Dialog global: un Dialog fuera del DataTable, no uno por fila.
- Hook para el estado:
openEditabre y pasa los datos,closeEditcierra y limpia. - DropdownMenu como disparador: en la cell solo los botones;
onClickllama aopenEdit(row.original).
Estructura clara: DataTable muestra los datos, DropdownMenu inicia las acciones, Dialog muestra el formulario, el Hook gestiona el flujo del estado.
Avanzado: patrón Context para evitar el prop drilling
Al combinar varios componentes, la trampa más común es el prop drilling: el estado baja nivel tras nivel y en el quinto ya no sabes de dónde viene esa prop.
Muchos componentes de shadcn/ui ya usan el patrón Compound Components, por ejemplo Card:
<Card>
<CardHeader>
<CardTitle>Título</CardTitle>
<CardDescription>Descripción</CardDescription>
</CardHeader>
<CardContent>Contenido</CardContent>
<CardFooter>Pie</CardFooter>
</Card>
Podrías preguntarte: «¿Cómo sabe CardTitle a qué Card pertenece? ¿Tengo que pasar un cardId?»
No. Los Compound Components comparten el estado vía Context; los hijos «saben» automáticamente en qué padre están.
Implementar una Card plegable
La Card de shadcn/ui no se pliega por defecto. Extendámosla para aprender el patrón Context:
// 1. Crea el Context
import { createContext, useContext, useState } from "react"
type CardContextValue = {
isCollapsed: boolean
toggle: () => void
}
const CardContext = createContext<CardContextValue | null>(null)
// 2. Root: gestiona el estado y provee el Context
CollapsibleCard.Root = { children, defaultCollapsed = false } => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardContext.Provider value={{
isCollapsed,
toggle: () => setIsCollapsed(!isCollapsed)
}}>
<Card className="border rounded-lg">{children}</Card>
</CardContext.Provider>
)
}
// 3. Header: título + botón de plegar
CollapsibleCard.Header = { title } => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Header must be in CollapsibleCard.Root")
return (
<CardHeader className="cursor-pointer" onClick={ctx.toggle}>
<div className="flex items-center justify-between">
<CardTitle>{title}</CardTitle>
{ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />}
</div>
</CardHeader>
)
}
// 4. Content: reacciona al estado plegado
CollapsibleCard.Content = { children } => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")
if (ctx.isCollapsed) return null // oculto si está plegado
return <CardContent>{children}</CardContent>
}
Uso:
<CollapsibleCard.Root defaultCollapsed={false}>
<CollapsibleCard.Header title="Información del usuario" />
<CollapsibleCard.Content>
<p>Nombre: Juan</p>
<p>Correo: [email protected]</p>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
Puntos clave:
- Context compartido: Root crea el Context, los hijos usan
useContext— sin prop drilling. - Hijos reactivos: Header cambia el estado, Content se muestra/oculta sin comunicación directa.
- Restricción al padre: si un hijo no está dentro de Root, error explícito.
Ventaja: combinas componentes sin preocuparte de cómo pasar el estado. Si el hijo está dentro del padre, obtiene el estado solo.
Escenario completo: DataTable + Dialog + Form
Juntando lo visto, una página completa de gestión de usuarios: DataTable para la lista, «Editar» abre Dialog con Form, el envío actualiza la tabla.
Los ejemplos completos están en las secciones anteriores; aquí el flujo esencial:
- Schema: Zod para estructura de datos y reglas de validación
- Hook Dialog: apertura/cierre y paso de datos en un solo lugar
- Columnas DataTable: columna de acciones con DropdownMenu
- Componente formulario de edición: Form + FormField + Input
- Página principal: DataTable + Dialog combinados
En este ejemplo ves todos los patrones:
- DataTable para los datos
- DropdownMenu para las acciones
- Dialog para el formulario
- Form para validación y envío
- Hook para el flujo del estado
Cada componente tiene un rol claro; el estado vía Hook y Context, sin prop drilling.
Técnicas avanzadas: rendimiento y type safety
Evitar re-renders causados por Context
Los Compound Components con Context son cómodos, pero hay una trampa: cuando cambia el valor del Context, todos los componentes con useContext se re-renderizan.
En CollapsibleCard, al plegar cambian Header y Content. Si Content contiene una lista pesada, el re-render puede ser lento.
Solución: separar los Context.
// Context de estado (cambia con frecuencia)
const CardStateContext = createContext<{ isCollapsed: boolean }>()
// Context de configuración (no cambia)
const CardConfigContext = createContext<{ collapsible: boolean }>()
// Root provee dos Context
CollapsibleCard.Root = { children, collapsible = true, defaultCollapsed = false } => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardConfigContext.Provider value={{ collapsible }}>
<CardStateContext.Provider value={{ isCollapsed }}>
<Card>
{children}
{/* Toggle separado para limitar el impacto del Context en los hijos */}
{collapsible && (
<button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Expandir" : "Contraer"}
</button>
)}
</Card>
</CardStateContext.Provider>
</CardConfigContext.Provider>
)
}
Header solo lee CardConfigContext (estable, sin re-renders innecesarios); Content solo lee CardStateContext (re-render solo cuando hace falta).
Type safety con TypeScript
Con Compound Components define los tipos con cuidado, si no TypeScript avisa de que «el hijo podría no estar en el padre».
// Definición de tipos completa
type CollapsibleCardProps = {
children: React.ReactNode
defaultCollapsed?: boolean
collapsible?: boolean
}
type CollapsibleCardComponents = {
Root: FC<CollapsibleCardProps>
Header: FC<{ title: string }>
Content: FC<{ children: React.ReactNode }>
}
const CollapsibleCard: CollapsibleCardComponents = {
Root: { children, defaultCollapsed = false, collapsible = true } => {
// ...
},
Header: { title } => {
// ...
},
Content: { children } => {
// ...
}
}
Así TypeScript comprueba las props:
// ✅ Correcto
<CollapsibleCard.Root defaultCollapsed={true}>
<CollapsibleCard.Header title="Título" />
<CollapsibleCard.Content>Contenido</CollapsibleCard.Content>
</CollapsibleCard.Root>
// ❌ Error TypeScript: title es obligatorio
<CollapsibleCard.Header />
Resumen: checklist de patrones de composición
En resumen, los principios clave:
Composiciones básicas
- Dialog + Form: Dialog contenedor, Form contenido — responsabilidades separadas
- DataTable + DropdownMenu: DropdownMenu inicia acciones, datos de fila vía
row.original - Tabs + Form: Tabs para navegación, TabsContent con formularios distintos
Técnicas intermedias
- Patrón Context: evita prop drilling, estado automático en los hijos
- Hook para el estado: Dialog único, sin instancia por fila
- Form + Zod: schema unificado, FormMessage para errores
Optimizaciones avanzadas
- Context separados: el estado que cambia a menudo no re-renderiza a todos los hijos
- Tipos TypeScript: definiciones completas, menos errores en props
- Separación Server/Client: con Next.js App Router, datos en Server, UI en Client
Último consejo: no modifiques directamente los archivos de componentes de shadcn/ui. Para personalizar, usa wrappers, variants o theme. Modificar el código fuente complica las actualizaciones futuras.
Después de aprender estos patrones, mi código quedó mucho más legible. Esa página de usuarios pasó de más de 300 líneas a menos de 150, con gestión de estado más clara. Context y optimizaciones al principio parecen complejos — con la práctica se vuelve natural.
¿Has tenido problemas similares al combinar componentes? Prueba estos patrones: deberían ayudarte a aclarar la estructura.
Implementar la composición DataTable + Dialog + Form
Flujo completo para una página de gestión de usuarios
⏱️ Estimated time: 45 min
- 1
Step 1: Definir el schema Zod
Define la validación de datos fuera del componente:
• Usa z.object() para los campos
• Añade reglas (min, email, enum)
• Exporta schema y type - 2
Step 2: Crear el Hook de estado del Dialog
Gestiona el estado del Dialog en un solo lugar:
• useState para open y editingUser
• openDialog abre y pasa los datos
• closeDialog cierra y resetea los datos - 3
Step 3: Definir las columnas del DataTable
Añade la columna de acciones en columns:
• En la cell pon DropdownMenu
• onClick llama a openDialog(row.original)
• No anides Dialog en la cell - 4
Step 4: Crear el componente formulario de edición
Usa el Form de shadcn:
• useForm + zodResolver
• FormField + FormControl
• FormMessage para errores automáticos - 5
Step 5: Componer la página principal
Combina todas las piezas:
• DataTable + Dialog global
• Form dentro del Dialog
• Tras el envío actualiza la lista
FAQ
¿Por qué no anidar Dialog en la cell del DataTable?
¿Form con React Hook Form o gestión manual?
• Validación y errores automáticos
• Type safety (z.infer)
• Mejor rendimiento (menos re-renders)
• FormMessage muestra los errores solo
¿El patrón Context causa problemas de rendimiento?
• Context separados (estado + configuración)
• Solo los hijos que deben reaccionar leen el Context de estado
• Configuración estable en Context dedicado
¿Cómo evitar el prop drilling?
¿Se pueden modificar los fuentes de shadcn/ui?
• Componentes wrapper
• Variants para las variantes
• Theme para los estilos
Modificar el código fuente complica las actualizaciones futuras.
¿Cómo tipificar los Compound Components en TypeScript?
• Props para Root/Header/Content
• FC<Props> en los componentes
• Error si el hijo no está dentro de Root
TypeScript comprueba que las props sean correctas.
12 min de lectura · Publicado el: 1 abr 2026 · Actualizado el: 21 ago 2026
Tailwind y shadcn/ui en práctica
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Modo oscuro en Tailwind: comparación entre class y data-theme
Comparación completa de las estrategias class y data-theme para el modo oscuro en Tailwind CSS: principios, configuración e integración con frameworks para elegir la mejor opción
Parte 7 de 14
Siguiente
shadcn/ui y Radix: cómo mantener la accesibilidad al personalizar componentes
shadcn/ui se basa en Radix Primitives. Aprende a preservar la accesibilidad al personalizar componentes: uso de asChild, gestión del foco y herencia ARIA, evitando fallos de navegación por teclado.
Parte 9 de 14



Comentarios
Inicia sesión con GitHub para dejar un comentario