Cambiar tema

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

Easton editorial illustration: performance tuning console

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>&lt;CardHeader>&lt;CardTitle>&lt;CardContent>, Dialog siempre es <Dialog>&lt;DialogContent>&lt;DialogHeader>&lt;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&#123;&#125;

  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(&#123;...formData, username: e.target.value&#125;)}
          />
          <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 &#123; useForm &#125; from "react-hook-form"
import &#123; zodResolver &#125; 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(&#123; user, onSubmit &#125;) {
  const [open, setOpen] = useState(false)
  const form = useForm(&#123;
    resolver: zodResolver(userSchema),
    defaultValues: user // pasa directamente los datos del usuario
  &#125;)

  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 &#123;...form&#125;>
          <form onSubmit={form.handleSubmit((data) => &#123;
            onSubmit(data)      // envía los datos
            setOpen(false)      // cierra el Dialog
          &#125;)}>
            <FormField
              name="username"
              render=&#123;(&#123; field &#125;) => (
                <FormItem>
                  <FormLabel>Nombre de usuario</FormLabel>
                  <FormControl>&lt;Input &#123;...field&#125; /></FormControl>
                  <FormMessage /> {/* errores automáticos */}
                </FormItem>
              )&#125;
            />
            <Button type="submit">Guardar</Button>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}

Puntos clave:

  1. Dialog es el contenedor, Form es el contenido: Dialog solo gestiona abrir/cerrar, Form gestiona datos/validación/envío — responsabilidades separadas.
  2. Form con React Hook Form + Zod: no gestiones el estado del formulario a mano; form.handleSubmit se encarga de la validación y el envío.
  3. 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 = [
  &#123;
    id: "actions",
    cell: &#123; row &#125; => (
      <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>
    )
  &#125;
]

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 = () => &#123;
  const [open, setOpen] = useState(false)
  const [editingUser, setEditingUser] = useState(null)

  const openEdit = (user) => &#123;
    setEditingUser(user)
    setOpen(true)
  &#125;

  const closeEdit = () => &#123;
    setOpen(false)
    setEditingUser(null)
  &#125;

  return &#123; open, editingUser, openEdit, closeEdit &#125;
&#125;

// 2. En las columnas solo el botón disparador
function UserDataTable(&#123; users &#125;) &#123;
  const &#123; open, editingUser, openEdit, closeEdit &#125; = useEditDialog()

  const columns = [
    &#123;
      id: "actions",
      cell: &#123; row &#125; => (
        <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>
      )
    &#125;
  ]

  return (
    <>
      <DataTable columns={columns} data={users} />
      {/* Un solo Dialog global */}
      <Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
        <DialogContent>
          <EditUserForm
            user={editingUser}
            onSubmit={(data) => &#123;
              updateUser(data)
              closeEdit()
              refreshTable() // actualiza los datos de la tabla
            &#125;}
          />
        </DialogContent>
      </Dialog>
    </>
  )
&#125;

Puntos clave:

  1. Dialog global: un Dialog fuera del DataTable, no uno por fila.
  2. Hook para el estado: openEdit abre y pasa los datos, closeEdit cierra y limpia.
  3. DropdownMenu como disparador: en la cell solo los botones; onClick llama a openEdit(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 &#123; createContext, useContext, useState &#125; from "react"

type CardContextValue = &#123;
  isCollapsed: boolean
  toggle: () => void
&#125;

const CardContext = createContext&lt;CardContextValue | null>(null)

// 2. Root: gestiona el estado y provee el Context
CollapsibleCard.Root = &#123; children, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardContext.Provider value=&#123;&#123;
      isCollapsed,
      toggle: () => setIsCollapsed(!isCollapsed)
    &#125;}>
      <Card className="border rounded-lg">&#123;children&#125;</Card>
    </CardContext.Provider>
  )
&#125;

// 3. Header: título + botón de plegar
CollapsibleCard.Header = &#123; title &#125; => &#123;
  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>&#123;title&#125;</CardTitle>
        &#123;ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />&#125;
      </div>
    </CardHeader>
  )
&#125;

// 4. Content: reacciona al estado plegado
CollapsibleCard.Content = &#123; children &#125; => &#123;
  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>&#123;children&#125;</CardContent>
&#125;

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:

  1. Context compartido: Root crea el Context, los hijos usan useContext — sin prop drilling.
  2. Hijos reactivos: Header cambia el estado, Content se muestra/oculta sin comunicación directa.
  3. 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:

  1. Schema: Zod para estructura de datos y reglas de validación
  2. Hook Dialog: apertura/cierre y paso de datos en un solo lugar
  3. Columnas DataTable: columna de acciones con DropdownMenu
  4. Componente formulario de edición: Form + FormField + Input
  5. 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&lt;&#123; isCollapsed: boolean &#125;>()

// Context de configuración (no cambia)
const CardConfigContext = createContext&lt;&#123; collapsible: boolean &#125;>()

// Root provee dos Context
CollapsibleCard.Root = &#123; children, collapsible = true, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardConfigContext.Provider value=&#123;&#123; collapsible &#125;}>
      <CardStateContext.Provider value=&#123;&#123; isCollapsed &#125;}>
        <Card>
          &#123;children&#125;
          {/* Toggle separado para limitar el impacto del Context en los hijos */}
          &#123;collapsible && (
            <button onClick={() => setIsCollapsed(!isCollapsed)}>
              &#123;isCollapsed ? "Expandir" : "Contraer"&#125;
            </button>
          )&#125;
        </Card>
      </CardStateContext.Provider>
    </CardConfigContext.Provider>
  )
&#125;

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 = &#123;
  children: React.ReactNode
  defaultCollapsed?: boolean
  collapsible?: boolean
&#125;

type CollapsibleCardComponents = &#123;
  Root: FC&lt;CollapsibleCardProps>
  Header: FC&lt;&#123; title: string &#125;>
  Content: FC&lt;&#123; children: React.ReactNode &#125;>
&#125;

const CollapsibleCard: CollapsibleCardComponents = &#123;
  Root: &#123; children, defaultCollapsed = false, collapsible = true &#125; => &#123;
    // ...
  &#125;,
  Header: &#123; title &#125; => &#123;
    // ...
  &#125;,
  Content: &#123; children &#125; => &#123;
    // ...
  &#125;
&#125;

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. 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. 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. 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. 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. 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?
Un Dialog por fila crea problemas de rendimiento. Con 100 filas tienes 100 Dialog y el estado es difícil de gestionar. La solución correcta es un Dialog global con Hook dedicado.
¿Form con React Hook Form o gestión manual?
Recomendamos React Hook Form + Zod:

• 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?
Sí. Cuando cambia el valor del Context, todos los componentes con useContext se re-renderizan. Soluciones:

• 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?
Patrón Context: Root crea el Context, los hijos usan useContext. Sin props en cascada — si el hijo está dentro del padre, obtiene el estado automáticamente.
¿Se pueden modificar los fuentes de shadcn/ui?
No recomendado. Mejor:

• 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?
Definición de tipos completa:

• 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

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog