Blog

Componentes flexibles en React con Compound Components

Crear un componente reutilizable en React suele empezar bien, siempre tenemos una idea clara: unas props, algo de contenido y una interacción. Lo usas en un par de pantallas y encaja, nada del otro mundo. El problema viene cuando en una necesitan mover el botón, en otra cambiar la cabecera y en una tercera dejar el contenido desplegado con una acción adicional.

Ningun requerimiento parece gran cosa. Añades una propiedad, resuelves el problema y sigues. Hasta que vuelves a abrir el componente y te encuentras veinte props y unas cuantas condiciones que hay que leer varias veces para saber qué combinaciones admite.

Para ese tipo de situaciones interesa el patrón Compound Components. Permite separar la interfaz en piezas que se pueden colocar de distintas formas y que siguen compartiendo un comportamiento. Vamos a ejemplificarlo con un panel desplegable: da para entrar en composición, estado, diseño de APIs y TypeScript sin montar una librería entera.

Nota: Los ejemplos están escritos y probados con React 19.2 y TypeScript con @types/react 19. Donde una API no existe en React 18, se indica la alternativa.

El problema de los componentes sobreconfigurados

Este panel no tiene demasiado misterio:

<Panel
  title="Información del envío"
  description="Consulta los plazos y condiciones."
/>

Con un título y una descripción probablemente bastaría para sus primeros usos. Pero empiezan a llegar variantes y acaba así:

<Panel
  title="Información del envío"
  description="Consulta los plazos y condiciones."
  showIcon
  iconPosition="left"
  showBadge
  badgeText="Gratis"
  collapsible
  defaultOpen
  showFooter
  footerText="Aplicable a pedidos superiores a 50 €"
  showAction
  actionLabel="Consultar condiciones"
  onAction={handleConditions}
  actionPosition="footer"
/>

Lo complicado aquí es tener que describir la estructura a base de flags. Para colocar una acción hay que indicar que existe, darle un texto, decir dónde va y pasar lo que hará al pulsarla. ¿Y cuándo hagan falta dos botones?

defaultOpen, en cambio, tiene un cometido bastante claro: establecer el estado inicial. El problema no está en que el componente reciba props, sino en cuánto estamos intentando expresar con ellas.

Por dentro, la acumulación suele tener este aspecto:

{showFooter && (
  <footer>
    {footerText && <p>{footerText}</p>}
    {showAction && actionPosition === "footer" && (
      <button onClick={onAction}>
        {actionLabel}
      </button>
    )}
  </footer>
)}

Si showFooter es false y actionPosition vale «footer», ya tenemos una combinación que obliga a pensar qué debería ocurrir. Cada variante añade otro camino y el componente va asumiendo las decisiones de presentación de todas las pantallas donde se usa.

Esto puede pasar sin que haya prop drilling: las veinte propiedades podrían consumirse en el mismo componente. Aquí el problema es que usamos props para describir toda la estructura.

¿Qué son los Compound Components?

Son componentes pensados para usarse juntos, con una responsabilidad para cada pieza y un contrato que define cómo se relacionan. Para nuestro panel, la API podría quedar así:

<Panel defaultOpen>
  <Panel.Trigger>
    Información del envío
  </Panel.Trigger>
  <Panel.Content>
    <p>Consulta los plazos y condiciones.</p>
  </Panel.Content>
</Panel>

Panel coordina el estado, Panel.Trigger abre y cierra, y Panel.Content muestra el contenido. Quien lo utiliza escribe la composición que necesita.

La notación con punto hace visible esa relación. Exportamos PanelRoot, PanelTrigger y PanelContent por separado y seguir usando el mismo patrón; añadir funciones como propiedades de otra función solo organiza la API de JavaScript. Para compartir el estado usaremos Context.

De un componente monolítico a una API compuesta

Con las piezas anteriores, el panel lleno de opciones se puede escribir de esta manera:

<Panel defaultOpen>
  <div className="panel-heading">
    <Panel.Trigger>
      Información del envío
    </Panel.Trigger>
    <span className="badge">Gratis</span>
  </div>
  <Panel.Content>
    <p>Consulta los plazos y condiciones.</p>
    <footer>
      <p>Aplicable a pedidos superiores a 50 €.</p>
      <a href="/condiciones-de-envio">
        Consultar condiciones
      </a>
    </footer>
  </Panel.Content>
</Panel>

Hay más JSX que en el primer ejemplo de dos props, pero ahora se ve dónde está cada cosa. Añadir otra acción consiste en escribirla en el sitio que corresponda; quitar el pie, en borrar el elemento. No hay que modificar el componente base por cada variación de contenido.

Se deja defaultOpen en el padre porque sigue siendo una opción de comportamiento. El texto y la distribución están en la pantalla que lo usa.

Tampoco hace falta crear Panel.Badge, Panel.Description, Panel.Footer y Panel.Link. Aquí el HTML normal nos sirve. Si más adelante ese footer necesita estilos y comportamiento compartidos, se puede extraer entonces y convertir cada etiqueta en una pieza de la API añade trabajo y no siempre ayuda a quien la consume.

Compartiendo estado entre Compound Components

El trigger necesita saber si el panel está abierto y poder cambiarlo. El contenido solo necesita consultar el state. Vamos a darles acceso con un contexto y un hook:

import {
  createContext,
  useContext,
  useId,
  useState,
  type ReactNode,
} from "react";
type PanelContextValue = {
  open: boolean;
  contentId: string;
  setOpen: (open: boolean) => void;
};
const PanelContext =
  createContext<PanelContextValue | null>(null);
function usePanelContext() {
  const context = useContext(PanelContext);
  if (context === null) {
    throw new Error(
      "Los componentes de Panel deben usarse dentro de <Panel>."
    );
  }
  return context;

}

El error del hook nos avisará si usamos una pieza fuera de Panel. Prefiero encontrar ese mensaje al probar la pantalla que un botón que no responde porque el contexto tiene una función vacía como valor por defecto.

La primera versión del componente raíz guardará su propio state:

type PanelRootProps = {
  children: ReactNode;
  defaultOpen?: boolean;
};
function PanelRoot({
  children,
  defaultOpen = false,
}: PanelRootProps) {
  const [open, setOpen] = useState(defaultOpen);
  const contentId = useId();
  return (
    <PanelContext.Provider
      value={{ open, setOpen, contentId }}
    >
      <div className="panel">{children}</div>
    </PanelContext.Provider>
  );
}

Y estas son las dos piezas que lo consumen:

function PanelTrigger({
  children,
}: {
  children: ReactNode;
}) {
  const { open, setOpen, contentId } = usePanelContext();
  return (
    <button
      type="button"
      aria-expanded={open}
      aria-controls={contentId}
      onClick={() => setOpen(!open)}
    >
      {children}
    </button>
  );
}
function PanelContent({
  children,
}: {
  children: ReactNode;
}) {
  const { open, contentId } = usePanelContext();
  return (
    <div id={contentId} hidden={!open}>
      {children}
    </div>
  );
}
export const Panel = Object.assign(PanelRoot, {
  Trigger: PanelTrigger,
  Content: PanelContent,
});

Los tres fragmentos van en el mismo archivo. Al final, Object.assign añade Trigger y Content al componente root, conservando sus tipos en TypeScript.

Para el trigger usamos un botón nativo, con su activación por teclado. aria-expanded indica si está abierto y aria-controls lo relaciona con el contenido mediante el identificador que genera useId. Esta implementación admite un trigger y un contenido por raíz.

He usado hidden para ocultar el contenido sin hacer unmount. Eso conserva, por ejemplo, el estado local de un formulario al cerrar el panel. También deja sus effects activos, algo que conviene tener presente si dentro hay más que texto. Si necesitamos hacer unmount, tendremos que cambiar ese comportamiento.

Desde React 19.2, <Activity> resuelve justo eso. Conserva el estado y el DOM, pero ejecuta el cleanup de los effects mientras el contenido está oculto y los vuelve a crear al mostrarlo:

import { Activity } from "react";

function PanelContent({ children }: { children: ReactNode }) {

  const { open, contentId } = usePanelContext();

  return (

    <Activity mode={open ? "visible" : "hidden"}>

      <div id={contentId}>{children}</div>

    </Activity>

  );

}

El div sigue en el DOM (React lo oculta con display: none), así que aria-controls sigue apuntando a un elemento que existe.

Esto es una decisión de diseño, no una mejora sin coste. Si algún contenido necesita que sus effects sigan activos con el panel cerrado, por ejemplo, una sincronización en segundo plano dejará de funcionar. Para un panel que muestra contenido, lo habitual es que pausar los effects sea lo correcto. Pero conviene documentarlo para quien use el componente.

En el ejemplo anterior había un div con la clase panel-heading entre la raíz y el trigger. No necesita recibir ni reenviar open: el contexto llega al consumer aunque haya elementos intermedios. Ahí sí estamos evitando prop drilling.

Los consumers se actualizan cuando cambia el valor del contexto. En este panel no necesitamos optimizarlo, lo mejor es dejarlo así,y si aparecen problemas de rendimiento, habrá que revisar ese valor y qué consume cada pieza.

Diseñando una API de componentes limpia

Antes de desarrollar todas las piezas, escribimos dos o tres usos: uno sencillo, otro con contenido adicional y otro con una distribución distinta. Es más fácil descubrir ahí que la API resulta incómoda que cuando ya está repartida por varias pantallas de producción.

Los nombres ayudan bastante. Con Trigger espero una interacción y con Content, el contenido asociado. Si encuentro un Panel.Manager, seguramente tenga que abrir su implementación para averiguar qué hace.

Después hay que decidir qué puede cambiar el consumer. Un texto auxiliar junto al trigger encaja en esta composición; un enlace dentro del botón, no, porque estaríamos anidando controles interactivos. También querrá pasar className, disabled o atributos de accesibilidad, aunque algunos, como aria-expanded, deben seguir ligados al estado del panel.

Los eventos merecen una decisión aparte. Si aceptamos onClick, ¿se ejecuta además de la apertura? ¿Puede cancelar esa apertura con preventDefault()? Más abajo implementaremos una de esas opciones. Lo que hay que buscar evitar es que la respuesta dependiese del orden en el que alguien haya colocado un spread.

No hace falta resolver toda la API desde el primer día. Sí dejaría junto a los ejemplos sus restricciones actuales: un contenido por raíz, el trigger dentro del provider y ningún control interactivo anidado en el botón.

Compound Components controlados y no controlados

Por ahora el panel es no controlado: guarda el state internamente y recibe su valor inicial en defaultOpen.

<Panel defaultOpen>
  <Panel.Trigger>Detalles</Panel.Trigger>
  <Panel.Content>
    <p>Información adicional del pedido.</p>
  </Panel.Content>
</Panel>

Si cambiamos defaultOpen después del mount, no estaremos dando una orden de apertura. Para eso necesitamos controlar el state desde fuera, por ejemplo cuando una pantalla quiere abrir el panel tras una acción o coordinarlo con otros elementos:

const [detailsOpen, setDetailsOpen] = useState(false);
<Panel
  open={detailsOpen}
  onOpenChange={setDetailsOpen}
>
  <Panel.Trigger>Detalles</Panel.Trigger>
  <Panel.Content>
    <p>Información adicional del pedido.</p>
  </Panel.Content>
</Panel>

El padre proporciona open y el panel le solicita los cambios mediante onOpenChange. Para admitir las dos formas de uso, sustituimos los tipos y la implementación de PanelRoot:

type PanelRootProps = {
  children: ReactNode;
} & (
  | {
      open: boolean;
      onOpenChange: (open: boolean) => void;
      defaultOpen?: never;
    }
  | {
      open?: never;
      defaultOpen?: boolean;
      onOpenChange?: (open: boolean) => void;
    }
);

function PanelRoot(props: PanelRootProps) {
  const [internalOpen, setInternalOpen] = useState(
    props.defaultOpen ?? false
  );
  const contentId = useId();
  const isControlled = props.open !== undefined;
  const open = props.open ?? internalOpen;
  function setOpen(nextOpen: boolean) {
    if (!isControlled) {
      setInternalOpen(nextOpen);
    }
    props.onOpenChange?.(nextOpen);
  }
  return (
    <PanelContext.Provider
      value={{ open, setOpen, contentId }}
    >
      <div className="panel">{props.children}</div>
    </PanelContext.Provider>
  );

}

No hay que tocar los subcomponentes. Siguen leyendo open y llamando a setOpen, independientemente de dónde se guarde el valor.

En el modo controlado, si el padre recibe el callback pero no actualiza open, el panel permanece como estaba. El callback solicita el cambio; es el padre quien lo aplica. Por ese mismo motivo mejor evitar copiar open continuamente a un estado interno mediante un effect: estamos manteniendo otra copia de un valor que ya controla el padre.

También mantendría el modo elegido durante toda la vida de la instancia. Cambiar de controlado a no controlado, o al revés, obliga a resolver qué estado debe prevalecer en ese momento.

Compound Components seguros con TypeScript

La unión de tipos anterior obliga a pasar onOpenChange cuando proporcionamos open e impide combinar open con defaultOpen. Son combinaciones que podemos descartar antes de ejecutar la aplicación.

Nos queda ampliar el trigger para aceptar propiedades de un botón. Usaremos los tipos nativos, reservando los atributos que necesita el panel para coordinarse:

import type { ComponentProps } from "react";
type PanelTriggerProps = Omit<
  ComponentProps<"button">,
  | "type"
  | "aria-expanded"
  | "aria-controls"

  | "dangerouslySetInnerHTML"
> & {
  children: ReactNode;
};


function PanelTrigger({
  children,
  onClick,
  ...props
}: PanelTriggerProps) {
  const { open, setOpen, contentId } = usePanelContext();
  return (
    <button
      {...props}
      type="button"
      aria-expanded={open}
      aria-controls={contentId}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) {
          setOpen(!open);
        }
      }}
    >
      {children}
    </button>
  );
}

Con esta versión podemos pasar disabled, className y otros atributos habituales. El onClick del consumer se ejecuta primero y, si llama a preventDefault(), se cancela la apertura. Los atributos de coordinación se colocan después de …props, de modo que el panel mantiene el control sobre ellos.

En React 19, ref es una prop más de los componentes de función. Al tipar con ComponentProps<«button»>, la ref entra en …props y llega hasta el <button> sin forwardRef ni cambios en la implementación:

const triggerRef = useRef<HTMLButtonElement>(null);

<Panel.Trigger ref={triggerRef}>Detalles</Panel.Trigger>

Esto sirve, por ejemplo, para devolver el foco al trigger después de una acción.

Si el componente tiene que funcionar también en React 18, la ref no llega como prop y hay que envolver PanelTrigger con forwardRef. En React 19 forwardRef sigue funcionando, pero ya no hace falta, y está previsto marcarlo como obsoleto.

Hay restricciones que estos tipos no comprueban. children: ReactNode admite composiciones sin Panel.Content, con varios contenidos o con un botón dentro de otro. Tampoco comprueba que Panel.Trigger tenga un provider por encima; de eso se ocupa el error del hook.

Para validar el comportamiento se prueba mediante la apertura mediante teclado, los atributos del botón, el modo controlado y que dos paneles mantengan estados independientes.

Cuándo funcionan bien los Compound Components

Me parecen útiles cuando cambia la composición entre pantallas, pero se mantiene el comportamiento. El panel puede contener texto, un formulario o varias acciones y seguir abriéndose de la misma forma. En unas pestañas, las piezas comparten la selección; en un menú, la relación entre el trigger y sus opciones.

En una biblioteca que usan varios equipos, esto permite ocuparse de las interacciones y las relaciones en la base, dejando a los consumers adaptar el contenido. Un menú o unas pestañas exigirán más trabajo que el panel de este artículo: navegación por teclado, gestión del foco y su semántica específica. Ese trabajo sigue siendo parte de la implementación.

Hay que plantearse el cambio al ver que cada nueva pantalla pide otra prop de posición, otro renderizador o una excepción para colocar contenido. En ese punto probaría si esa necesidad se expresa mejor dejando que el consumer coloque una pieza directamente.

Cuándo no deberías usarlos

Un aviso que siempre tiene el mismo formato puede quedarse así:

<Notice
  variant="warning"
  message="Tu sesión está a punto de caducar."
/>

Tener que escribir Notice.Root, Notice.Icon, Notice.Body y Notice.Message cada vez sería bastante más pesado para un caso tan sencillo. No hay que convertir ese componente en una API compuesta solo por usar el patrón.

Para mostrar y ocultar información, además, hay que mirar details y summary. Pueden ahorrarnos buena parte del código. El panel de este artículo nos sirve para estudiar la implementación; en una aplicación habría que valorar qué necesitamos que no resuelva ya el navegador.

También hay interfaces donde una configuración resulta más cómoda. Si el servidor entrega una lista de campos o acciones, probablemente tenga más sentido construir la interfaz a partir de esos datos que obligarse a expresar cada caso mediante una composición manual.

Y acotaría el contexto al componente. Apertura, selección o foco son estados que sus piezas pueden necesitar compartir. Autenticación, permisos, peticiones y datos de varias páginas pertenecen a una discusión más amplia sobre la arquitectura de la aplicación.

Compound Components vs. props de configuración

Podemos combinar las dos formas sin problema. Las props siguen sirviendo para datos y opciones acotadas como size, disabled, open o una variante visual. La composición nos deja decidir qué contenido aparece y dónde:

<Panel defaultOpen>
  <Panel.Trigger disabled={isLoading}>
    Información del pedido
  </Panel.Trigger>
  <Panel.Content>
    <OrderSummary order={order} />
  </Panel.Content>
</Panel>

disabled configura el trigger y order aporta los datos al resumen. Lo que ya no necesitamos es una colección de banderas para describir toda la estructura.

Incluso podemos ofrecer una versión más cerrada sobre las piezas originales:

function HelpPanel({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) {
  return (
    <Panel>
      <Panel.Trigger>{title}</Panel.Trigger>
      <Panel.Content>{children}</Panel.Content>
    </Panel>
  );
}

Si casi todas las pantallas usan esa distribución, HelpPanel ahorra repetirla. Cuando una necesite algo distinto, podrá recurrir a Panel.Trigger y Panel.Content. Me resulta una combinación práctica para que el equipo tenga una opción sencilla sin perder el acceso a las piezas.

Conclusiones

Lo normal es empezar con un componente normal y observar qué cambios van llegando. Si son valores o variantes bien definidas y limitadas, seguiría añadiendo props. Si cambian continuamente la estructura, mejor probar una API compuesta con esos usos delante.

Para comprobarlo, se lee el JSX del siguiente caso real. Si resulta fácil ver qué va a mostrarse y cómo funciona, la API está ayudando. Si el equipo necesita más explicaciones que con el componente anterior, tenemos que revisar qué se puede simplificar.

Referencias

Manuel López Camarena
Manuel López Camarena

Senior Fullstack Developer

Let’s shape the future together