У компонентно-орієнтованій архітектурі React ключовим викликом є баланс між інкапсуляцією стану та гнучкістю композиції інтерфейсу. Традиційні монолітні компоненти з десятками конфігураційних пропсів (god components, prop drilling) швидко деградують: додавання кастомних іконок, підказок (tooltips) або зміна розмітки вимагає постійного розширення API компонента.
Сучасна компонентна архітектура React 19 спирається на два фундаментальні патерни:
Історично складені компоненти будувалися на базі імперативного обходу React.Children.map і клонування через React.cloneElement. У сучасному React 19 та високонавантажених дизайн-системах цей підхід визнано антипатерном з трьох причин:
cloneElement працює виключно з прямими нащадками першого рівня. Якщо огорнути <TabsTrigger /> у сітковий контейнер <div> або HOC-компонент, зв'язок із батьківським провайдером розривається.div або span, порушуючи CSS Grid, Flexbox та доступність (WAI-ARIA).{...props} стирає користувацькі обробники onClick або перезаписує посилання ref.Сучасним стандартом індустрії є зв'язка React Context + Radix Slot (asChild).
Замість того щоб компонент-батько нав'язував жорстку розмітку, він надає простір станів і контрактів, делегуючи розміщення елементів клієнтському коду.
При використанні asChild компонент не рендерить власний DOM-елемент. Він клонує свого безпосереднього нащадка (React.isValidElement(children)), здійснюючи злиття (Prop Merging) своїх внутрішніх атрибутів (ARIA-ролі, ID, слухачі подій клавіатури, стилі) з пропсами, явно переданими користувачем.
event.preventDefault(), системна поведінка бібліотеки скасовується.forwardedRef та внутрішнього ref нащадка) в єдиний RefCallback.twMerge / clsx.// Тригер табів не рендерить <button>, а передає свою поведінку всередину Next.js Link:
<Tabs.Trigger asChild value="analytics">
<Link href="/analytics">Аналітика</Link>
</Tabs.Trigger>Компонент перевіряє наявність булевого прапорця asChild. Якщо false — рендериться стандартний HTML-елемент (наприклад, button).
Перевірка React.isValidElement(children) та гарантія відсутності фрагментів із декількома дітьми (Children.only).
Злиття className через Tailwind Merge, ланцюжок обробників подій та об'єднання ref через mergeRefs.
Виклик React.cloneElement без створення додаткового DOM-контейнера. До DOM потрапляє лише чистий цільовий вузол.
Чому в промисловій архітектурі Compound Components (наприклад, Tabs.Root, Tabs.List, Tabs.Trigger) стан вкладок передається через React Context, а не через React.Children.map та React.cloneElement?
Button, Menu, Select, Dialog), які можуть виступати як нативними HTML-тегами (<button>, <select>), так і посиланнями фреймворку (<Link> із Next.js) без втрати стилів і доступності.<ul>/<li> до табличних рядків <tr>/<td> без модифікації JS-логіки.<a> для роботів пошукових систем замість <button onClick={navigate}> зі збереженням усієї функціональності дропдауна або тултипа.Slot перевіряє children через React.isValidElement. Якщо передано декілька вузлів або текст, викидається runtime-помилка контракту.
Об'єднання зовнішнього ref компонента Slot та внутрішнього ref дочірнього елемента через єдиний callback-ref.
Створення складеного обробника: спочатку викликається користувацький обробник дитини, потім системний обробник слота (якщо default не запобігли).
Рендеринг вихідного VNode дитини з розширеними пропсами без створення додаткового wrapper-вузла.
Нижче наведено сувору реалізацію злиття пропсів без використання типу any:
import * as React from 'react';
/**
* Об'єднує декілька ref-об'єктів або 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;
}
});
};
}
/**
* Об'єднує обробники подій.
* Якщо childHandler викликає event.preventDefault(), наш handler не виконається (поведінка 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;
}
/**
* Примітив Slot: замінює себе переданим React-елементом
*/
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 із прапорцем asChild очікує рівно один дочірній елемент.');
}
return null;
});
Slot.displayName = 'Slot';
/**
* Внутрішня функція злиття атрибутів, стилів та подій
*/
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;
}Користувач клікає на <Tabs.Trigger asChild value='tab2'><a href='/tab2' onClick={handleClick}>Вкладка</a></Tabs.Trigger>. Всередині handleClick викликано event.preventDefault(). Яку поведінку гарантує функція composeEventHandlers?
Створимо повністю доступний (WAI-ARIA Tabs 1.2) компонент табів, що демонструє спільну роботу патерну Context та asChild:
'use client';
import * as React from 'react';
import { Slot, composeEventHandlers } from './slot';
// ─── Контекст складеного компонента ──────────────────────────────
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}] повинен використовуватися всередині <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,
};Спроба прокинути activeTab або onChange через React.Children.map(children, child => React.cloneElement(child, ...)) ламається в той момент, коли споживач огортає тригер у проміжний контейнер <div> або сітку колонок. Клонування впливає виключно на дітей першого рівня.
// ❌ АНТИПАТЕРН: Крихка передача пропсів через cloneElement
function BadTabsList({ children, activeTab }: { children: React.ReactNode; activeTab: string }) {
return (
<div>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
// Якщо дитина обгорнута в <div><TabsTrigger /></div>, проп activeTab втратиться!
return React.cloneElement(child, { activeTab } as Record<string, unknown>);
})}
</div>
);
}
// ✅ ПРАВИЛЬНО: Ізоляція через Context API
function GoodTabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>; // Діти самі беруть стан з useTabsContext()
}Наївне поширення пропсів {...childProps} {...parentProps} призведе до того, що обробник події батька повністю зітре користувацький onClick, оголошений на дочірньому елементі, або навпаки.
// ❌ АНТИПАТЕРН: Пряме перезаписування слухача
function BadSlot({ children, onClick }: { children: React.ReactElement; onClick: () => void }) {
return React.cloneElement(children, {
...children.props,
onClick, // Повністю замінює children.props.onClick!
});
}
// ✅ ПРАВИЛЬНО: Композиція обробників
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),
});
}Патерн Slot за контрактом замінює себе одним DOM-вузлом. Якщо розробник передає фрагмент <><span>1</span><span>2</span></>, текстовий вузол або кілька елементів, React не зможе злити пропси та викине помилку гідратації або виняток.
// ❌ АНТИПАТЕРН: Кілька вузлів всередині asChild
<Tabs.Trigger asChild value="tab1">
<span>Іконка</span>
<span>Текст вкладки</span>
</Tabs.Trigger>
// ✅ ПРАВИЛЬНО: Рівно один кореневий елемент-нащадок
<Tabs.Trigger asChild value="tab1">
<button className="flex items-center gap-2">
<span>Іконка</span>
<span>Текст вкладки</span>
</button>
</Tabs.Trigger>Якщо при реалізації кастомного компонента, що передається в asChild, не прокинути ref на реальну HTML-ноду, механізми керування фокусом (Focus Trap, roving tabindex, Floating UI) не зможуть визначити координати елемента. Завжди використовуйте mergeRefs.