Componentes Compuestos — Diseña APIs de componentes multi-parte que comparten estado implícito a través de contexto
Receta
// Componente compuesto: Select con estado compartido
const SelectContext = createContext<{
value: string;
onChange: (value: string) => void;
} | null>(null);
function Select({ value, onChange, children }: {
value: string;
onChange: (value: string) => void;
children: React.ReactNode;
}) {
return (
<SelectContext value={{ value, onChange }}>
<div role="listbox">{children}</div>
</SelectContext>
);
}
function Option({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = use(SelectContext);
if (!ctx) throw new Error("Option debe usarse dentro de Select");
const isSelected = ctx.value === value;
return (
<div
role="option"
aria-selected={isSelected}
onClick={() => ctx.onChange(value)}
className={isSelected ? "bg-blue-100 font-semibold" : ""}
>
{children}
</div>
);
}
Select.Option = Option;
// Uso — se lee como una API declarativa
<Select value={selected} onChange={setSelected}>
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
<Select.Option value="svelte">Svelte</Select.Option>
</Select>Cuándo usarlo: Cuando construyas un componente multi-parte donde los sub-componentes necesitan estado compartido pero el consumidor debe controlar la estructura y el orden. Piensa en <Tabs>/<Tab>, <Accordion>/<AccordionItem>, <Menu>/<MenuItem>.
Ejemplo Funcional
import { createContext, use, useState, useId, type ReactNode } from "react";
// --- Componente compuesto Accordion ---
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const ctx = use(AccordionContext);
if (!ctx) throw new Error("Los sub-componentes de Accordion deben usarse dentro de <Accordion>");
return ctx;
}
// Raíz
function Accordion({
children,
multiple = false,
defaultOpen = [],
}: {
children: ReactNode;
multiple?: boolean;
defaultOpen?: string[];
}) {
const [openItems, setOpenItems] = useState<Set<string>>(
() => new Set(defaultOpen)
);
const toggle = (id: string) => {
setOpenItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!multiple) next.clear();
next.add(id);
}
return next;
});
};
return (
<AccordionContext value={{ openItems, toggle, multiple }}>
<div className="divide-y border rounded-lg">{children}</div>
</AccordionContext>
);
}
// Elemento
interface ItemContextValue {
itemId: string;
isOpen: boolean;
}
const ItemContext = createContext<ItemContextValue | null>(null);
function Item({ id, children }: { id: string; children: ReactNode }) {
const { openItems } = useAccordion();
const isOpen = openItems.has(id);
return (
<ItemContext value={{ itemId: id, isOpen }}>
<div>{children}</div>
</ItemContext>
);
}
function useItem() {
const ctx = use(ItemContext);
if (!ctx) throw new Error("Debe usarse dentro de <Accordion.Item>");
return ctx;
}
// Disparador
function Trigger({ children }: { children: ReactNode }) {
const { toggle } = useAccordion();
const { itemId, isOpen } = useItem();
const contentId = `accordion-content-${itemId}`;
return (
<button
className="w-full text-left p-4 flex justify-between items-center"
onClick={() => toggle(itemId)}
aria-expanded={isOpen}
aria-controls={contentId}
>
{children}
<span className={`transition-transform ${isOpen ? "rotate-180" : ""}`}>
▼
</span>
</button>
);
}
// Contenido
function Content({ children }: { children: ReactNode }) {
const { itemId, isOpen } = useItem();
const contentId = `accordion-content-${itemId}`;
if (!isOpen) return null;
return (
<div id={contentId} role="region" className="p-4 pt-0 text-gray-600">
{children}
</div>
);
}
// Adjunta sub-componentes
Accordion.Item = Item;
Accordion.Trigger = Trigger;
Accordion.Content = Content;
// --- Uso ---
function FAQPage() {
return (
<Accordion defaultOpen={["general"]}>
<Accordion.Item id="general">
<Accordion.Trigger>¿Qué es React?</Accordion.Trigger>
<Accordion.Content>
React es una biblioteca de JavaScript para crear interfaces de usuario.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="hooks">
<Accordion.Trigger>¿Qué son los hooks?</Accordion.Trigger>
<Accordion.Content>
Los hooks te permiten usar state y otras características de React en componentes de función.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}Lo que esto demuestra:
- Contexto de dos niveles:
AccordionContextpara estado compartido,ItemContextpara estado por elemento - API declarativa limpia en el sitio de la llamada
- Los consumidores controlan la estructura, orden y estilos
- Atributos ARIA manejados internamente por el componente compuesto
Análisis Profundo
Cómo Funciona
- Un componente padre crea un contexto con estado compartido y acciones.
- Los sub-componentes secundarios consumen ese contexto usando
use()(React 19) ouseContext(). - Los sub-componentes se adjuntan como propiedades estáticas en el padre (p. ej.,
Select.Option). - El consumidor compone estas partes en JSX, controlando el orden y los elementos contenedores.
- Cada sub-componente valida que está renderizado dentro del padre esperado a través de una verificación de contexto nulo.
Parámetros y Valores de Retorno
| Rol del Componente | Responsabilidades |
|---|---|
Raíz (p. ej. Accordion) | Posee estado compartido, proporciona contexto, renderiza contenedor externo |
Contenedor de elemento (p. ej. Accordion.Item) | Delimita contexto por elemento, mapea identidad del elemento |
Disparador (p. ej. Accordion.Trigger) | Maneja interacción del usuario, se conecta al estado compartido |
Contenido (p. ej. Accordion.Content) | Renderiza condicionalmente basado en estado compartido |
Variaciones
Validación flexible de secundarios — acepta sub-componentes en cualquier lugar del árbol, no solo como elementos secundarios directos:
// Los componentes compuestos basados en contexto funcionan a cualquier profundidad
<Accordion>
<div className="custom-wrapper">
{/* Funciona porque Item lee contexto, no elementos secundarios directos */}
<Accordion.Item id="nested">
<Accordion.Trigger>Todavía funciona</Accordion.Trigger>
<Accordion.Content>El contexto fluye a través de cualquier profundidad</Accordion.Content>
</Accordion.Item>
</div>
</Accordion>Componente compuesto controlado — permite que el padre controle el estado abierto:
function Accordion({
openItems,
onToggle,
children,
}: {
openItems: Set<string>;
onToggle: (id: string) => void;
children: ReactNode;
}) {
return (
<AccordionContext value={{ openItems, toggle: onToggle, multiple: true }}>
<div>{children}</div>
</AccordionContext>
);
}Notas de TypeScript
- Escribe valores de contexto como uniones con
nully verificanullen el hook del consumidor. - Exporta tipos de props de sub-componentes para consumidores que necesiten extenderlos.
- Usa aumento de módulos o tipos de intersección si adjuntar sub-componentes a un componente de función crea problemas de tipo.
Trampas
-
Sub-componente usado fuera del padre — El contexto será
null, causando bugs silenciosos o crashes. Solución: Lanza un error descriptivo en el hook personalizado cuando el contexto esnull. -
Contexto obsoleto con elementos secundarios memoizados — Si un elemento secundario está envuelto en
React.memo, puede que no se re-renderice cuando cambia el contexto. Solución: Asegúrate de que los elementos secundarios memoizados aún consuman contexto directamente, no a través de props. -
Dividir excesivamente el contexto — Crear demasiadas capas de contexto añade complejidad. Solución: Comienza con un único contexto; divide solo cuando la creación de perfiles revela re-renderizaciones innecesarias.
-
Incompatibilidad con componentes del servidor — Los componentes compuestos que usan contexto requieren
"use client". Solución: Marca el archivo del componente compuesto con"use client"y acepta elementos secundarios de componentes del servidor a través deReactNode.
Alternativas
| Enfoque | Compensación |
|---|---|
| Componentes compuestos | API hermosa, flexible; requiere configuración de contexto |
| Objeto de configuración | <Tabs items={[...]}/> — más simple pero menos control flexible de estructura |
| Props de renderización | Flujo de datos explícito; más boilerplate en sitio de llamada |
| Hooks sin cabeza | Sin opinión de JSX; el consumidor construye todo desde cero |
| Composición basada en espacios | Más simple; las sub-partes no pueden comunicarse sin prop drilling |
FAQs
¿Qué es un componente compuesto en React?
- Un componente compuesto es un conjunto de componentes relacionados que comparten estado implícito a través de contexto.
- Un componente padre posee el estado y lo proporciona a través de contexto; los sub-componentes secundarios lo consumen.
- Los ejemplos incluyen
<Tabs>/<Tab>,<Accordion>/<AccordionItem>, y<Select>/<Option>.
¿Cómo comparten estado los componentes compuestos entre sub-componentes?
- El padre crea un contexto de React con estado compartido y funciones de acción.
- Los sub-componentes consumen el contexto usando
use()(React 19) ouseContext(). - Esto permite que los sub-componentes se comuniquen sin prop drilling.
¿Por qué adjuntar sub-componentes como propiedades estáticas como Select.Option?
- Crea una API limpia y descubrible:
<Select.Option>se lee como una relación declarativa. - Los consumidores pueden importar solo el componente raíz y acceder a todos los sub-componentes a través de él.
- Agrupa componentes relacionados lógicamente sin requerir importaciones separadas.
¿Cómo funciona el contexto de dos niveles en el ejemplo de Accordion?
// Nivel 1: AccordionContext — estado compartido (openItems, toggle)
// Nivel 2: ItemContext — estado por elemento (itemId, isOpen)
<AccordionContext value={{ openItems, toggle, multiple }}>
<ItemContext value={{ itemId: id, isOpen }}>
{children}
</ItemContext>
</AccordionContext>AccordionContextproporciona estado compartido para todos los elementos.ItemContextdelimita la identidad por elemento para que Trigger y Content sepan a qué elemento pertenecen.
Trampa: ¿Qué sucede si se usa un sub-componente fuera de su padre?
- El contexto será
null, causando bugs silenciosos o crashes. - Solución: lanza un error descriptivo en el hook personalizado cuando el contexto es
null. - Ejemplo:
if (!ctx) throw new Error("Option debe usarse dentro de Select");
Trampa: ¿Por qué los componentes compuestos que usan contexto deben estar marcados como "use client" en Next.js?
- El contexto requiere React del lado del cliente para crear, proporcionar y consumir estado.
- Los componentes del servidor no pueden usar
createContext,use(), ouseState. - Solución: marca el archivo del componente compuesto con
"use client"y acepta elementos secundarios de componentes del servidor a través deReactNode.
¿Cómo escribes el valor de contexto de un componente compuesto en TypeScript?
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);- Escribe el contexto como una unión con
nully usanullcomo valor por defecto. - Verifica
nullen el hook personalizado y lanza si falta. - Exporta el tipo de valor de contexto para que los consumidores puedan extenderlo si es necesario.
¿Cómo manejas problemas de TypeScript al adjuntar sub-componentes a un componente de función?
- Asignar
Accordion.Item = Itempuede causar errores de tipo porque los componentes de función no tienen un tipo de propiedad estática por defecto. - Solución: usa aumento de módulos, tipos de intersección, o declara el componente con un tipo explícito que incluya las propiedades estáticas.
- Alternativamente, exporta sub-componentes por separado y deja que los consumidores los importen individualmente.
¿Pueden los sub-componentes de un componente compuesto anidarse a cualquier profundidad en el árbol de JSX?
- Sí, porque los componentes compuestos basados en contexto funcionan a cualquier profundidad, no solo como elementos secundarios directos.
- Un sub-componente envuelto en elementos
<div>personalizados seguirá consumiendo el contexto del padre. - Esta es una ventaja clave sobre patrones que se basan en
React.Childrenpara inspeccionar elementos secundarios directos.
¿Cómo haces que un componente compuesto admita modo controlado?
function Accordion({
openItems,
onToggle,
children,
}: {
openItems: Set<string>;
onToggle: (id: string) => void;
children: ReactNode;
}) {
return (
<AccordionContext value={{ openItems, toggle: onToggle, multiple: true }}>
<div>{children}</div>
</AccordionContext>
);
}- Acepta el estado y las devoluciones de llamada como props desde el padre en lugar de gestionarlos internamente.
- El padre se convierte en la única fuente de verdad sobre qué elementos están abiertos.
¿Cuándo deberías usar una API de objeto de configuración en lugar de componentes compuestos?
- Usa un objeto de configuración (
<Tabs items={[...]} />) cuando el consumidor no necesita control sobre la estructura o el diseño. - Usa componentes compuestos cuando el consumidor necesita controlar el orden, los elementos contenedores y el estilos de sub-partes.
- Los componentes compuestos son más flexibles pero requieren más configuración.
¿Cómo evitas re-renderizaciones innecesarias en componentes compuestos cuando cambia el contexto?
- Divide el contexto en contextos de estado y acciones separados para que los consumidores de solo acciones no se re-rendericen en cambios de estado.
- Memoriza el objeto de acciones con
useMemopara mantener su referencia estable. - Comienza con un único contexto y divide solo cuando la creación de perfiles revela problemas de rendimiento.
Relacionado
- Composition — Patrón fundacional en el que se basan los componentes compuestos
- Context Patterns — Análisis profundo en rendimiento de contexto y división
- Controlled vs Uncontrolled — Haciendo que los componentes compuestos sean controlables externamente