In React's component-driven architecture, balancing state encapsulation with flexible UI composition is a key engineering challenge. Traditional monolithic components loaded with dozens of configuration props (god components, prop drilling) quickly degrade: adding custom icons, tooltips, or adjusting layout markup demands continuous expansion of the component's API surface.
Modern React 19 component architecture relies on two foundational patterns:
Historically, compound components relied on imperative traversal with React.Children.map and cloning via React.cloneElement. In modern React 19 and production-scale design systems, this approach is recognized as an antipattern for three core reasons:
cloneElement only inspects immediate first-level children. If a consumer wraps <TabsTrigger /> inside a grid container <div> or a styled layout wrapper, communication with the parent provider breaks.div or span elements, breaking CSS Grid, Flexbox, and accessibility tree semantics (WAI-ARIA).{...props} inadvertently wipes out consumer onClick handlers or overwrites internal DOM ref instances.The modern industry standard pairs React Context + Radix-style Slot (asChild).
Instead of a parent component dictating concrete DOM markup, it exposes a state space and behavioral contract, delegating element placement and semantic tags to consumer code.
When using asChild, the component avoids rendering its own DOM node. It clones its immediate child (React.isValidElement(children)), performing Prop Merging across internal attributes (ARIA roles, generated IDs, keyboard navigation handlers, styles) and consumer-supplied props.
event.preventDefault(), the library's default system action is aborted.ref and the child's internal ref) into a single composite RefCallback.clsx and twMerge.// Tabs trigger does not render a <button>, but delegates its behavior directly into a Next.js Link:
<Tabs.Trigger asChild value="analytics">
<Link href="/analytics">Analytics</Link>
</Tabs.Trigger>The component checks the boolean asChild flag. If false, it renders its default HTML element (such as a standard button).
Validates React.isValidElement(children) and enforces that exactly one child element is present (React.Children.only).
Merges className strings, chains event handlers with defaultPrevented checks, and unifies refs via mergeRefs.
Invokes React.cloneElement without creating extra wrapper containers in the DOM. Only the single target element enters the tree.
Why does enterprise-grade Compound Component architecture (e.g., Tabs.Root, Tabs.List, Tabs.Trigger) share state via React Context rather than React.Children.map and React.cloneElement?
Button, Menu, Select, Dialog) that can act either as native HTML elements (<button>, <select>) or framework routers (<Link> from Next.js) without sacrificing styling or accessibility.<ul>/<li> lists to table rows <tr>/<td> without modifying JavaScript state logic.<a> tags for search engine bots instead of <button onClick={navigate}>, while retaining full dropdown and tooltip behaviors.Slot checks children via React.isValidElement. If multiple elements or plain text are passed, it throws a runtime contract violation.
Combines the external ref forwarded to the Slot with the child's own ref into a unified callback ref.
Chains handlers: the child's consumer handler executes first, followed by the slot's internal handler (unless default was prevented).
Renders the child's original VNode augmented with merged props without inserting an extra wrapper element into the DOM.
Below is a strict TypeScript implementation of prop merging that avoids the any escape hatch:
import * as React from 'react';
/**
* Merges multiple ref objects or ref callbacks into a single 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;
}
});
};
}
/**
* Composes event handlers.
* If childHandler calls event.preventDefault(), our internal handler will not fire (Radix UI behavior)
*/
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;
}
/**
* Slot primitive: replaces itself with the provided child React element
*/
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]: Slot component with asChild expects exactly one child element.');
}
return null;
});
Slot.displayName = 'Slot';
/**
* Internal helper to merge attributes, styles, and event listeners
*/
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;
}A user clicks on <Tabs.Trigger asChild value='tab2'><a href='/tab2' onClick={handleClick}>Tab</a></Tabs.Trigger>. Inside handleClick, event.preventDefault() is called. What behavior does composeEventHandlers guarantee?
Here is a fully accessible (WAI-ARIA Tabs 1.2) compound tabs component demonstrating Context and asChild interoperability:
'use client';
import * as React from 'react';
import { Slot, composeEventHandlers } from './slot';
// ─── Compound Component Context ──────────────────────────────────
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}] must be rendered inside a <Tabs.Root> provider.`);
}
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,
};Attempting to pass activeTab or onChange via React.Children.map(children, child => React.cloneElement(child, ...)) breaks whenever a consumer wraps a trigger inside an intermediate <div> or grid layout. Cloning strictly targets immediate first-level children.
// ❌ ANTIPATTERN: Fragile prop forwarding via cloneElement
function BadTabsList({ children, activeTab }: { children: React.ReactNode; activeTab: string }) {
return (
<div>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
// If child is wrapped in <div><TabsTrigger /></div>, activeTab is lost!
return React.cloneElement(child, { activeTab } as Record<string, unknown>);
})}
</div>
);
}
// ✅ RECOMMENDED: Tree isolation via Context API
function GoodTabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>; // Children retrieve state via useTabsContext()
}Naive prop spreading {...childProps} {...parentProps} results in the parent's event handler completely obliterating any consumer onClick listener registered on the child element, or vice versa.
// ❌ ANTIPATTERN: Direct listener overwrite
function BadSlot({ children, onClick }: { children: React.ReactElement; onClick: () => void }) {
return React.cloneElement(children, {
...children.props,
onClick, // Completely replaces children.props.onClick!
});
}
// ✅ RECOMMENDED: Event handler composition
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),
});
}The Slot pattern fundamentally contracts to replace itself with one DOM element. If a consumer passes a React Fragment <><span>1</span><span>2</span></>, text nodes, or multiple sibling elements, React cannot safely merge props and will throw hydration errors or runtime exceptions.
// ❌ ANTIPATTERN: Multiple sibling nodes inside asChild
<Tabs.Trigger asChild value="tab1">
<span>Icon</span>
<span>Tab Title</span>
</Tabs.Trigger>
// ✅ RECOMMENDED: Exactly one root child element
<Tabs.Trigger asChild value="tab1">
<button className="flex items-center gap-2">
<span>Icon</span>
<span>Tab Title</span>
</button>
</Tabs.Trigger>If a custom component passed into asChild fails to forward its ref to an underlying native HTML node, focus management utilities (Focus Trap, roving tabindex, Floating UI) cannot calculate DOM coordinates or set focus. Always ensure proper ref composition using mergeRefs.