Patrones de composición: compound components

Lectura
30 min~6 min lectura
CONCEPTO CLAVE: Los Compound Components son un patrón de composición en React donde múltiples componentes trabajan juntos para formar una unidad funcional cohesiva. El componente padre gestiona el estado y la lógica, mientras que los componentes hijos exponen una API declarativa e intuitiva para el desarrollador que los consume.

¿Qué son los Compound Components?

Imagina que necesitas crear un componente de Select con opciones. Podrías hacerlo con props:

// Enfoque tradicional con props

Funciona, pero ¿qué pasa cuando necesitas algo más complejo? El patrón Compound Components resuelve esto:// Enfoque Compound Components

  
  
    React
    Vue
    Angular
  

📌 Este patrón es exactamente lo que usa Radix UI, Headless UI y @radix-ui/react-select para crear componentes accesibles sin opinión sobre el estilo.

¿Por qué usar Compound Components?

Los beneficios principales son:

  • Flexibilidad: El consumidor puede personalizar la estructura del HTML sin romper la funcionalidad.
  • Estado compartido: El padre e hijos comparten estado sin necesidad de Context API manual.
  • Composición natural: El JSX resultante refleja la estructura visual del componente.
  • Reutilización: Los componentes hijos funcionan en diferentes contextos.

Implementación paso a paso

  1. Crear el contexto: Primero, necesitas un contexto que relacione los componentes hijos con el padre.
// SelectContext.js
import { createContext, useContext, useState } from 'react';

const SelectContext = createContext(null);

export function SelectProvider({ children, value, onChange }) {
  return (
    
      {children}
    
  );
}

export function useSelectContext() {
  const context = useContext(SelectContext);
  if (!context) {
    throw new Error('Select components must be used within SelectProvider');
  }
  return context;
}
  1. Crear el componente padre: Gestiona el estado y renderiza los hijos.
// Select.jsx
export function Select({ children, onChange }) {
  const [isOpen, setIsOpen] = useState(false);
  
  const toggle = () => setIsOpen(prev => !prev);
  const close = () => setIsOpen(false);
  
  const value = isOpen; // O el valor real seleccionado
  
  return (
    
      <div className="select-container" onBlur={close}>
        {children}
      </div>
    
  );
}
  1. Crear los componentes hijos: Cada hijo consume el contexto.
// Select.jsx (continuación)
Select.Trigger = function SelectTrigger({ placeholder }) {
  const { value, onChange } = useSelectContext();
  
  return (
    <button 
      className="select-trigger"
      onClick={() => onChange(!value)}
    >
      {placeholder || 'Seleccionar...'}
      <span className="arrow">{value ? '▲' : '▼'}</span>
    </button>
  );
};

Select.Options = function SelectOptions({ children }) {
  const { value } = useSelectContext();
  
  if (!value) return null;
  
  return <ul className="select-options">{children}</ul>;
};

Select.Option = function SelectOption({ children, value }) {
  const { onChange } = useSelectContext();
  
  return (
    <li 
      className="select-option"
      onClick={() => onChange(false)}
    >
      {children}
    </li>
  );
};
💡 Tip: Aunque puedes usar Object.assign() para añadir propiedades estáticas, es más limpio y mantenible definir cada componente como una propiedad del objeto padre después de definir todos los componentes.

Ejemplo práctico completo

Vamos a crear un componente Tabs que demuestra el poder de este patrón:

// Tabs.jsx
import { createContext, useContext, useState } from 'react';

const TabsContext = createContext(null);

export function Tabs({ children, defaultTab }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">
        {children}
      </div>
    </TabsContext.Provider>
  );
}

Tabs.List = function TabsList({ children }) {
  return <div className="tabs-list" role="tablist">{children}</div>;
};

Tabs.Tab = function TabsTab({ children, value }) {
  const { activeTab, setActiveTab } = useContext(TabsContext);
  const isActive = activeTab === value;
  
  return (
    <button
      role="tab"
      aria-selected={isActive}
      className={`tab ${isActive ? 'active' : ''}`}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
};

Tabs.Panel = function TabsPanel({ children, value }) {
  const { activeTab } = useContext(TabsContext);
  
  if (activeTab !== value) return null;
  
  return (
    <div role="tabpanel" className="tab-panel">
      {children}
    </div>
  );
};

// Uso:
<Tabs defaultTab="react">
  <Tabs.List>
    <Tabs.Tab value="react">React</Tabs.Tab>
    <Tabs.Tab value="vue">Vue</Tabs.Tab>
    <Tabs.Tab value="angular">Angular</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="react">Contenido de React...</Tabs.Panel>
  <Tabs.Panel value="vue">Contenido de Vue...</Tabs.Panel>
  <Tabs.Panel value="angular">Contenido de Angular...</Tabs.Panel>
</Tabs>
⚠️ Advertencia: No confundas Compound Components con Control Props Pattern. En Compound Components, el estado pertenece al padre y los hijos solo lo consumen. No deben intentar modificar el estado directamente (salvo que sea intencional con callbacks).

Patrones avanzados

Colección implícita

Permite que los hijos se registren automáticamente sin que el padre los conozca:

// Menu.jsx - los items se auto-registran
const MenuContext = createContext(null);

function Menu({ children }) {
  const [items, setItems] = useState([]);
  
  const registerItem = (item) => {
    setItems(prev => [...prev, item]);
  };
  
  return (
    <MenuContext.Provider value={{ items, registerItem }}>
      <nav className="menu">{children}</nav>
    </MenuContext.Provider>
  );
}

Menu.Item = function MenuItem({ children, onClick }) {
  const { registerItem } = useContext(MenuContext);
  
  useEffect(() => {
    registerItem({ onClick, children });
  }, []);
  
  return <button onClick={onClick}>{children}</button>;
};

Slots pattern

Permite reemplazar partes específicas del componente:

function Card({ children, slots }) {
  return (
    <div className="card">
      {slots?.header || <default header />}
      <div className="card-body">{children}</div>
      {slots?.footer}
    </div>
  );
}

Card.Slot = function CardSlot({ name, children }) {
  return { name, children };
};

// Uso
<Card slots={{
  header: <Card.Slot name="header">Título personalizado</Card.Slot>,
  footer: <Card.Slot name="footer">Acciones</Card.Slot>
}}>
  Contenido principal
</Card>
"La composición es la capacidad de combinar funciones simples para construir funciones más complejas. En React, los Compound Components son la manifestación de este principio en la UI." — Kent C. Dodds
📌 Recuerda: Los Compound Components shine cuando necesitas un componente que sea Flexible en su uso, Intuitivo en su API, y Consistente en su comportamiento.
Ver más: Ejemplo con Formularios

Los formularios son un caso de uso perfecto para Compound Components:

function Form({ children, onSubmit }) {
  const [data, setData] = useState({});
  const [errors, setErrors] = useState({});
  
  return (
    <FormContext.Provider value={{ data, setData, errors, setErrors }}>
      <form onSubmit={handleSubmit(onSubmit)}>{children}</form>
    </FormContext.Provider>
  );
}

Form.Field = function FormField({ name, label, children, rules }) {
  const { errors, setErrors } = useFormContext();
  const error = errors[name];
  
  return (
    <div className="form-field">
      <label>{label}</label>
      {children}
      {error && <span className="error">{error.message}</span>}
    </div>
  );
};

Form.Input = function FormInput({ name, ...props }) {
  const { data, setData } = useFormContext();
  
  return (
    <input
      name={name}
      value={data[name] || ''}
      onChange={e => setData(prev => ({ ...prev, [name]: e.target.value }))}
      {...props}
    />
  );
};

// Uso
<Form onSubmit={handleLogin}>
  <Form.Field name="email" label="Email">
    <Form.Input type="email" required />
  </Form.Field>
  <Form.Field name="password" label="Contraseña">
    <Form.Input type="password" required />
  </Form.Field>
  <button type="submit">Enviar</button>
</Form>

Mejores prácticas

PrácticaDescripción
Validación de contextoSempre lanza un error descriptivo si se usa fuera del provider
Nombres clarosUsa nombres como Tab.Panel no TabContent
Composición vs PropsUsa composición cuando hay múltiples piezas configurables
Tipos TypeScriptExporta tipos para autocomplete del consumidor
💡 Pro tip: Si tu componente necesita más de 5 props de configuración, considera si Compound Components podría simplificar la API.
🧠 Quiz

¿Cuál es el propósito principal del patrón Compound Components?

  • A) Reducir el número de archivos en el proyecto
  • B) Crear componentes flexibles donde el estado se comparte entre padre e hijos mediante un contexto
  • C) Eliminar la necesidad de usar props en React
  • D) Reemplazar completamente a Redux para gestión de estado
✅ Respuesta correcta: B. El patrón Compound Components permite crear componentes flexibles y declarativos donde el componente padre provee un contexto que los hijos consumen, compartiendo estado sin props drilling.

Conclusión

Los Compound Components son un patrón poderoso que te permite crear APIs de componentes más expresivas y flexibles. librerías como Radix UI, Chakra UI y Headless UI utilizan este patrón extensivamente porque:

  • Permite separación total entre lógica y presentación
  • Hace que los componentes sean más testeables
  • Da máxima flexibilidad al consumidor final
  • Crea interfaces declarativas que se leen como sentences

Practica refactorizando componentes con muchas props hacia este patrón. Notarás cómo la experiencia de desarrollo mejora drásticamente.