В компонентно-ориентированной архитектуре React ключевым вызовом является баланс между инкапсуляцией состояния и гибкостью композиции интерфейса. Традиционные монолитные компоненты с десятками конфигурационных пропсов (god components, prop drilling) быстро деградируют: добавление кастомных иконок, тултипов или изменение разметки требует постоянного расширения 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.