¿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
¿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
- 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;
}
- 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>
);
}
- 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>
);
};
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>
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
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áctica | Descripción |
|---|---|
| Validación de contexto | Sempre lanza un error descriptivo si se usa fuera del provider |
| Nombres claros | Usa nombres como Tab.Panel no TabContent |
| Composición vs Props | Usa composición cuando hay múltiples piezas configurables |
| Tipos TypeScript | Exporta tipos para autocomplete del consumidor |
¿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
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.