En la arquitectura orientada a componentes de React, un reto crucial de ingeniería es el equilibrio entre la encapsulación del estado y la flexibilidad de la composición de la interfaz. Los componentes monolíticos tradicionales sobrecargados con docenas de props de configuración (god components, prop drilling) se degradan rápidamente: añadir iconos personalizados, tooltips o modificar la estructura del maquetado exige una expansión constante de la API del componente.
La arquitectura moderna de componentes de React 19 se fundamenta en dos patrones esenciales:
Históricamente, los componentes compuestos se construían mediante el recorrido imperativo con React.Children.map y la clonación mediante React.cloneElement. En el React 19 moderno y en sistemas de diseño de gran escala, este enfoque se considera un antipatrón por tres razones críticas:
cloneElement solo inspecciona hijos directos de primer nivel. Si el desarrollador envuelve <TabsTrigger /> dentro de un contenedor en cuadrícula <div> o en un componente HOC, la comunicación con el proveedor padre se interrumpe.div o span, rompiendo CSS Grid, Flexbox y la accesibilidad (WAI-ARIA).{...props} borra accidentalmente los manejadores onClick del consumidor o sobrescribe las instancias ref del DOM.El estándar de la industria actual combina React Context + Radix Slot (asChild).
En lugar de que el componente padre imponga una estructura DOM rígida, expone un espacio de estados y contratos de comportamiento, delegando la colocación de los elementos y las etiquetas semánticas al código del consumidor.
Al utilizar asChild, el componente no renderiza su propio nodo DOM. Clona a su hijo inmediato (React.isValidElement(children)), llevando a cabo una fusión de propiedades (Prop Merging) entre sus atributos internos (roles ARIA, IDs generados, escuchadores de teclado, estilos) y las props declaradas explícitamente por el usuario.
event.preventDefault(), el comportamiento por defecto de la librería se aborta.forwardedRef externo y el ref interno del hijo) en un único RefCallback.clsx y twMerge.// El trigger de pestañas no renderiza un <button>, sino que delega su comportamiento en un Link de Next.js:
<Tabs.Trigger asChild value="analytics">
<Link href="/analytics">Analítica</Link>
</Tabs.Trigger>El componente comprueba el booleano asChild. Si es false, renderiza su elemento HTML estándar (por ejemplo, un button).
Verifica React.isValidElement(children) y garantiza la ausencia de fragmentos con múltiples hijos (React.Children.only).
Fusión de className con Tailwind Merge, encadenamiento de manejadores con soporte para defaultPrevented y unión de refs con mergeRefs.
Invocación de React.cloneElement sin crear contenedores DOM adicionales. Solo el nodo de destino llega al árbol del DOM.
¿Por qué en la arquitectura industrial de Compound Components (por ejemplo, Tabs.Root, Tabs.List, Tabs.Trigger) el estado se distribuye mediante React Context en lugar de React.Children.map y React.cloneElement?
Button, Menu, Select, Dialog) capaces de operar tanto como etiquetas HTML nativas (<button>, <select>) o componentes de enrutamiento (<Link> de Next.js) sin degradar el diseño ni la accesibilidad.<ul>/<li> hasta filas de tabla <tr>/<td> sin alterar la lógica de estado en JavaScript.<a> reales para rastreadores de motores de búsqueda en vez de <button onClick={navigate}>, preservando todas las funcionalidades del dropdown o tooltip.Slot evalúa children mediante React.isValidElement. Si se reciben múltiples elementos o texto sin contenedor, emite un error de contrato en tiempo de ejecución.
Unificación del ref externo recibido por el Slot y el ref interno del hijo en un único callback-ref.
Encadenamiento de manejadores: primero se ejecuta el handler del hijo del consumidor; si no se canceló por defecto, se ejecuta el handler interno del slot.
Renderizado del VNode original del hijo aumentado con las props combinadas sin inyectar nodos envoltorios adicionales.
A continuación se muestra una implementación estricta en TypeScript para la fusión de props sin recurrir a tipos any:
import * as React from 'react';
/**
* Combina múltiples objetos ref o callbacks de ref en un único callback ref
*/
export function mergeRefs<T>(
...refs: (React.Ref<T> | undefined | null)[]
): React.RefCallback<T> {
return (value: T) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
} else {
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}
/**
* Compone manejadores de eventos.
* Si childHandler ejecuta event.preventDefault(), nuestro handler interno no se disparará (comportamiento Radix)
*/
export function composeEventHandlers<E extends React.SyntheticEvent>(
originalHandler?: (event: E) => void,
ourHandler?: (event: E) => void,
{ checkForDefaultPrevented = true }: { checkForDefaultPrevented?: boolean } = {}
) {
return (event: E) => {
originalHandler?.(event);
if (checkForDefaultPrevented === false || !event.defaultPrevented) {
ourHandler?.(event);
}
};
}
interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
}
/**
* Primitivo Slot: se sustituye a sí mismo por el elemento React hijo proporcionado
*/
export const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
if (React.isValidElement(children)) {
return React.cloneElement<Record<string, unknown>>(
children,
mergeProps(slotProps, children.props as Record<string, unknown>, forwardedRef)
);
}
if (React.Children.count(children) > 1) {
throw new Error('[Slot]: El componente Slot con la bandera asChild requiere exactamente un elemento hijo.');
}
return null;
});
Slot.displayName = 'Slot';
/**
* Función interna para fusionar atributos, estilos y manejadores de eventos
*/
function mergeProps(
slotProps: Record<string, unknown>,
childProps: Record<string, unknown>,
forwardedRef: React.Ref<HTMLElement>
): Record<string, unknown> {
const overrideProps: Record<string, unknown> = { ...childProps };
for (const propName in childProps) {
const slotPropValue = slotProps[propName];
const childPropValue = childProps[propName];
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler) {
if (slotPropValue && childPropValue) {
overrideProps[propName] = composeEventHandlers(
childPropValue as (event: React.SyntheticEvent) => void,
slotPropValue as (event: React.SyntheticEvent) => void
);
} else if (slotPropValue) {
overrideProps[propName] = slotPropValue;
}
} else if (propName === 'className') {
overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
} else if (propName === 'style') {
overrideProps[propName] = {
...(slotPropValue as React.CSSProperties),
...(childPropValue as React.CSSProperties),
};
}
}
const result: Record<string, unknown> = { ...slotProps, ...overrideProps };
const childRef = (childProps as { ref?: React.Ref<HTMLElement> }).ref;
result.ref = forwardedRef || childRef ? mergeRefs(forwardedRef, childRef) : undefined;
return result;
}El usuario hace clic en <Tabs.Trigger asChild value='tab2'><a href='/tab2' onClick={handleClick}>Pestaña</a></Tabs.Trigger>. Dentro de handleClick se ejecuta event.preventDefault(). ¿Qué comportamiento garantiza la función composeEventHandlers?
A continuación se presenta un componente de pestañas completamente accesible (WAI-ARIA Tabs 1.2) que combina el patrón Context y asChild:
'use client';
import * as React from 'react';
import { Slot, composeEventHandlers } from './slot';
// ─── Contexto del componente compuesto ───────────────────────────
interface TabsContextValue {
activeTab: string;
setActiveTab: (value: string) => void;
baseId: string;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
function useTabsContext(componentName: string): TabsContextValue {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error(`[${componentName}] debe utilizarse dentro de un proveedor <Tabs.Root>.`);
}
return context;
}
// ─── Root ────────────────────────────────────────────────────────
export interface TabsRootProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
function Root({
defaultValue,
value,
onValueChange,
children,
className,
...props
}: TabsRootProps) {
const [activeTab, setActiveTabState] = React.useState(defaultValue);
const baseId = React.useId();
const isControlled = value !== undefined;
const currentTab = isControlled ? value : activeTab;
const setActiveTab = React.useCallback(
(nextTab: string) => {
if (!isControlled) {
setActiveTabState(nextTab);
}
onValueChange?.(nextTab);
},
[isControlled, onValueChange]
);
const contextValue = React.useMemo(
() => ({ activeTab: currentTab, setActiveTab, baseId }),
[currentTab, setActiveTab, baseId]
);
return (
<TabsContext.Provider value={contextValue}>
<div className={className} {...props}>
{children}
</div>
</TabsContext.Provider>
);
}
// ─── List ────────────────────────────────────────────────────────
export interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {}
function List({ className, ...props }: TabsListProps) {
return (
<div
role="tablist"
aria-orientation="horizontal"
className={className}
{...props}
/>
);
}
// ─── Trigger ─────────────────────────────────────────────────────
export interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
asChild?: boolean;
}
const Trigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ value, asChild = false, onClick, className, children, ...props }, ref) => {
const { activeTab, setActiveTab, baseId } = useTabsContext('Tabs.Trigger');
const isSelected = activeTab === value;
const triggerProps = {
role: 'tab',
id: `${baseId}-trigger-${value}`,
'aria-selected': isSelected,
'aria-controls': `${baseId}-content-${value}`,
tabIndex: isSelected ? 0 : -1,
onClick: composeEventHandlers(onClick, () => setActiveTab(value)),
className,
...props,
};
if (asChild) {
return (
<Slot ref={ref as React.Ref<HTMLElement>} {...triggerProps}>
{children}
</Slot>
);
}
return (
<button ref={ref} type="button" {...triggerProps}>
{children}
</button>
);
}
);
Trigger.displayName = 'TabsTrigger';
// ─── Content ─────────────────────────────────────────────────────
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
asChild?: boolean;
forceMount?: boolean;
}
const Content = React.forwardRef<HTMLDivElement, TabsContentProps>(
({ value, asChild = false, forceMount = false, className, children, ...props }, ref) => {
const { activeTab, baseId } = useTabsContext('Tabs.Content');
const isSelected = activeTab === value;
if (!isSelected && !forceMount) {
return null;
}
const contentProps = {
role: 'tabpanel',
id: `${baseId}-content-${value}`,
'aria-labelledby': `${baseId}-trigger-${value}`,
hidden: !isSelected,
tabIndex: 0,
className,
...props,
};
if (asChild) {
return (
<Slot ref={ref as React.Ref<HTMLElement>} {...contentProps}>
{children}
</Slot>
);
}
return (
<div ref={ref} {...contentProps}>
{children}
</div>
);
}
);
Content.displayName = 'TabsContent';
export const Tabs = {
Root,
List,
Trigger,
Content,
};El intento de propagar activeTab o onChange con React.Children.map(children, child => React.cloneElement(child, ...)) falla en cuanto el consumidor envuelve un trigger en un contenedor intermedio <div> o en un grid de columnas. La clonación solo afecta a los elementos hijos de primer nivel.
// ❌ ANTIPATRÓN: Paso frágil de props mediante cloneElement
function BadTabsList({ children, activeTab }: { children: React.ReactNode; activeTab: string }) {
return (
<div>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
// Si el hijo está envuelto en <div><TabsTrigger /></div>, ¡la prop activeTab se pierde!
return React.cloneElement(child, { activeTab } as Record<string, unknown>);
})}
</div>
);
}
// ✅ RECOMENDADO: Aislamiento mediante Context API
function GoodTabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>; // Los hijos extraen el estado de useTabsContext()
}La propagación directa de props {...childProps} {...parentProps} provocará que el manejador de eventos del padre borre por completo cualquier listener onClick registrado en el hijo por el usuario, o viceversa.
// ❌ ANTIPATRÓN: Sobrescritura directa del listener
function BadSlot({ children, onClick }: { children: React.ReactElement; onClick: () => void }) {
return React.cloneElement(children, {
...children.props,
onClick, // ¡Sustituye completamente children.props.onClick!
});
}
// ✅ RECOMENDADO: Composición de manejadores de eventos
function GoodSlot({ children, onClick }: { children: React.ReactElement; onClick: (e: React.MouseEvent) => void }) {
return React.cloneElement(children, {
...children.props,
onClick: composeEventHandlers((children.props as { onClick?: (e: React.MouseEvent) => void }).onClick, onClick),
});
}El patrón Slot asume el compromiso de sustituirse a sí mismo por un único nodo del DOM. Si el desarrollador pasa un fragmento <><span>1</span><span>2</span></>, texto sin envolver o múltiples elementos hermanos, React no podrá combinar las props y arrojará errores de hidratación o excepciones en ejecución.
// ❌ ANTIPATRÓN: Múltiples nodos hermanos dentro de asChild
<Tabs.Trigger asChild value="tab1">
<span>Icono</span>
<span>Texto de pestaña</span>
</Tabs.Trigger>
// ✅ RECOMENDADO: Exactamente un único elemento raíz hijo
<Tabs.Trigger asChild value="tab1">
<button className="flex items-center gap-2">
<span>Icono</span>
<span>Texto de pestaña</span>
</button>
</Tabs.Trigger>Si al implementar un componente personalizado suministrado a asChild no se redirige el ref al nodo HTML subyacente, las utilidades de gestión del foco (Focus Trap, roving tabindex, Floating UI) no podrán calcular las dimensiones ni ubicar el elemento. Emplee siempre mergeRefs.