Коротко: Разбираем реализацию Slot Pattern в React и TypeScript: от именованных слотов до asChild в Radix UI. Избавляемся от prop-drilling и лишних DOM-узлов.
Создание масштабируемой библиотеки компонентов почти всегда упирается в одну и ту же проблему: со временем простые элементы интерфейса обрастают десятками специфических пропсов. Кнопка получает флаги hasLeftIcon, badgeText, tooltipContent и isLoading. Карточка превращается в монолит с двадцатью свойствами для настройки заголовка, подзаголовка, аватара и действий. В результате кодовая база замусоривается транзитными параметрами (prop-drilling), а любое нестандартное требование дизайнера приводит к очередному ветвлению внутри компонента.
Slot Pattern (паттерн слотов) решает эту проблему через инверсию управления (Inversion of Control). Вместо передачи примитивных данных и конфигурационных флагов компонент предоставляет именованные области («слоты»), куда вызывающий код помещает готовую разметку.
Рассмотрим, как устроен этот паттерн, какие архитектурные ограничения есть у стандартного API React и как реализовать надежные слоты на TypeScript без деградации доступности и лишних DOM-элементов.
Анатомия проблемы: антипаттерны конфигурирования UI
Когда UI-kit только создается, разработчики часто выбирают конфигурационный подход. Компонент проектируется как жесткий шаблон, поведением и внешним видом которого управляют булевы флаги и строковые значения.
Проп-дриллинг и комбинаторный взрыв пропсов
Типичный пример — компонент Card или Header:
// Антипаттерн: раздутый интерфейс с высокой связностью
interface CardProps {
title: string;
subtitle?: string;
avatarUrl?: string;
avatarFallback?: string;
badgeText?: string;
badgeVariant?: 'success' | 'warning';
showActionButton?: boolean;
actionButtonText?: string;
onActionClick?: () => void;
// Еще 15 пропсов под каждый новый кейс...
}
Такая структура порождает каскадный проп-дриллинг: Card вынужден принимать параметры, которые сам не использует, и пробрасывать их глубже — в Avatar, Badge или Button. Если в карточке потребуется отобразить не просто текстовый бейдж, а бейдж с иконкой и выпадающим списком, этот API сломается. Придется либо добавлять новые пропсы (badgeIcon, onBadgeClick), либо полностью переписывать внутренности.
Лишние DOM-обёртки и поломка CSS-сетки
Пытаясь сделать компонент универсальным, разработчики часто оборачивают части разметки в условные контейнеры:
export const Header = ({ leftContent, rightContent, title }: HeaderProps) => (
<header className="header">
{leftContent && <div className="header-left">{leftContent}</div>}
<h1>{title}</h1>
{rightContent && <div className="header-right">{rightContent}</div>}
</header>
);
Когда компонент начинает использоваться внутри Flexbox или CSS Grid, эти промежуточные div создают паразитные уровни вложенности в дереве DOM. Это нарушает правила выравнивания (align-items, justify-content), ломает расчет размеров (fr, auto) и усложняет написание селекторов.
Что такое Slot Pattern в экосистеме React
Slot Pattern — это архитектурный подход, заимствованный из спецификации Web Components и фреймворков вроде Vue, адаптированный под декларативную природу React. Основная идея: родительский компонент отвечает за сетку, лейаут, базовую логику состояний и ARIA-структуру, а наполнение визуальных зон делегируется потребителю.
От props.children к именованным слотам (ReactNode)
В React базовой формой слота выступает props.children. Это неименованный одиночный слот, куда попадает все, что заключено между открывающим и закрывающим тегами.
Когда областей для вставки несколько, роль слотов берут на себя явные свойства, принимающие тип React.ReactNode:
interface PageHeaderProps {
title: React.ReactNode;
actions?: React.ReactNode;
breadcrumbs?: React.ReactNode;
}
export const PageHeader = ({ title, actions, breadcrumbs }: PageHeaderProps) => (
<div className="page-header">
{breadcrumbs && <nav className="page-header__nav">{breadcrumbs}</nav>}
<div className="page-header__main">
<div className="page-header__title">{title}</div>
{actions && <div className="page-header__actions">{actions}</div>}
</div>
</div>
);
В такой схеме PageHeader ничего не знает о том, какие именно кнопки находятся в actions. Это может быть одна кнопка, группа кнопок или выпадающее меню с фильтрами.
Ограничения React.Children: почему манипуляции хрупки
Иногда авторы библиотек пытаются парсить children, чтобы автоматически распределить элементы по нужным местам:
// Хрупкий подход: не рекомендуется официальной документацией React
React.Children.map(props.children, child => {
if (React.isValidElement(child) && child.type === HeaderIcon) {
// ручная вставка в определенный контейнер
}
});
В React структура children считается opaque (непрозрачной). Попытка проверять child.type или вызывать React.cloneElement в цикле приводит к проблемам:
- Оборачивание дочернего элемента в HOC (High-Order Component),
React.memoили кастомный провайдер ломает проверкуchild.type. - Фрагменты (
<React.Fragment>или<>...</>) не разворачиваются автоматически. - Код становится хрупким, трудно типизируемым и скрывает реальный поток данных.
Реализация паттерна слотов: от простого к продвинутому
Существует три основных способа интеграции слотов в кодовую базу. Выбор зависит от требований к изоляции стилей и гибкости разметки.
1. Именованные слоты через пропсы (Named Slots)
Это самый простой, типизированный и производительный вариант. Слоты объявляются как пропсы, принимающие ReactNode или компонент.
import React from 'react';
interface DialogLayoutProps {
header: React.ReactNode;
body: React.ReactNode;
footer?: React.ReactNode;
}
export const DialogLayout: React.FC<DialogLayoutProps> = ({ header, body, footer }) => (
<div className="dialog" role="dialog" aria-modal="true">
<div className="dialog__header">{header}</div>
<div className="dialog__body">{body}</div>
{footer && <div className="dialog__footer">{footer}</div>}
</div>
);
Плюсы: предельная прозрачность, нативная поддержка автодополнения в IDE.
Минусы: при большом количестве слотов JSX-вызов с передачей длинных фрагментов в пропсы выглядит перегруженным.
2. Составные компоненты (Compound Components) с контекстом
Подход, при котором родительский компонент предоставляет набор дочерних подкомпонентов. Связь между ними осуществляется через React Context.
import React, { createContext, useContext } from 'react';
interface CardContextType {
isHovered?: boolean;
}
const CardContext = createContext<CardContextType | null>(null);
export const CardRoot = ({ children }: { children: React.ReactNode }) => (
<CardContext.Provider value={{}}>
<div className="card-root">{children}</div>
</CardContext.Provider>
);
export const CardHeader = ({ children }: { children: React.ReactNode }) => (
<div className="card-header">{children}</div>
);
export const CardBody = ({ children }: { children: React.ReactNode }) => (
<div className="card-body">{children}</div>
);
export const CardActions = ({ children }: { children: React.ReactNode }) => (
<div className="card-actions">{children}</div>
);
// Сборка составного компонента
export const Card = Object.assign(CardRoot, {
Header: CardHeader,
Body: CardBody,
Actions: CardActions,
});
Использование:
<Card>
<Card.Header>
<h2>Аналитика проекта</h2>
</Card.Header>
<Card.Body>
<p>Основное содержимое карточки...</p>
</Card.Body>
<Card.Actions>
<Button variant="primary">Экспорт</Button>
</Card.Actions>
</Card>
Плюсы: декларативность, естественная структура разметки, гибкость в порядке вывода.
Минусы: если порядок вывода элементов жестко зафиксирован требованиями дизайна, пользователь может случайно его нарушить.
3. Headless Slot и паттерн asChild
Паттерн, ставший стандартом де-факто благодаря Radix UI. Он решает задачу полиморфизма: позволяет компоненту дизайн-системы не создавать свой DOM-узел, а передавать все свои свойства, классы, accessibility-атрибуты и обработчики событий напрямую первому дочернему элементу.
Вместо устаревшего пропса as="a" используется логический флаг asChild:
// Если asChild=false: рендерится обычный <button>
<Button variant="primary">Сохранить</Button>
// Если asChild=true: свойства Button мерджатся в компонент Link из Next.js
<Button asChild variant="primary">
<Link href="/dashboard">Перейти в панель</Link>
</Button>
Практика: пишем собственный Slot и мерджим пропсы на TypeScript
Чтобы реализовать asChild, необходим компонент Slot, который берет пропсы родителя и аккуратно объединяет их с пропсами дочернего элемента.
При объединении важно соблюдать правила:
- Обработчики событий: должны вызываться оба обработчика (и переданный в
Slot, и определенный на дочернем элементе). При этом дочерний обработчик имеет приоритет (если вызванevent.defaultPrevented, родительский может не выполняться). - Классы (
className): объединяются через конкатенацию. - Стили (
style): объединяются через spread-оператор (дочерние переопределяют родительские). - Ссылки (
ref): объединяются, чтобы и библиотека, и конечный потребитель получили доступ к реальному DOM-узлу.
import React, { isValidElement, cloneElement, forwardRef } from 'react';
// Утилита для объединения нескольких ref
function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T) => {
refs.forEach(ref => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.MutableRefObject<T | null>).current = node;
}
});
};
}
// Утилита для объединения обработчиков событий
function composeEventHandlers<E extends React.SyntheticEvent>(
originalHandler?: (event: E) => void,
ourHandler?: (event: E) => void,
{ checkForDefaultPrevented = true } = {}
) {
return (event: E) => {
originalHandler?.(event);
if (checkForDefaultPrevented === false || !event.defaultPrevented) {
return ourHandler?.(event);
}
};
}
export interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
}
export const Slot = forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
if (!isValidElement(children)) {
return null;
}
// Извлекаем пропсы дочернего элемента
const childProps = children.props as Record<string, any>;
// Формируем объединенный объект пропсов
const mergedProps: Record<string, any> = {
...slotProps,
...childProps,
// Слияние className
className: [slotProps.className, childProps.className].filter(Boolean).join(' ') || undefined,
// Слияние стилей
style: {
...slotProps.style,
...childProps.style,
},
};
// Объединяем обработчики событий (onClick, onKeyDown и т.д.)
for (const propName in slotProps) {
if (propName.startsWith('on') && typeof slotProps[propName] === 'function') {
mergedProps[propName] = composeEventHandlers(
childProps[propName],
slotProps[propName]
);
}
}
// Объединяем ссылки (refs)
const childRef = (children as any).ref;
mergedProps.ref = forwardedRef || childRef
? composeRefs(forwardedRef, childRef)
: undefined;
return cloneElement(children, mergedProps);
});
Slot.displayName = 'Slot';
Теперь любой компонент системы может поддерживать бесшовную подмену корневого тега:
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: 'primary' | 'secondary';
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, className, variant = 'primary', ...props }, ref) => {
const Component = asChild ? Slot : 'button';
const variantClass = `btn btn--${variant}`;
return (
<Component
ref={ref as any}
className={[variantClass, className].filter(Boolean).join(' ')}
{...props}
/>
);
}
);
Button.displayName = 'Button';
Сравнение подходов в production: Radix UI vs MUI X vs кастомный UI-kit
Различные библиотеки реализуют слоты по-разному в зависимости от архитектурных целей:
| Критерий | Radix UI (Slot / asChild) |
MUI X (slots и slotProps) |
Кастомные Compound Components |
|---|---|---|---|
| Основная цель | Устранение wrapper-тегов и замена тега/компонента | Глубокая кастомизация внутренних узлов сложных виджетов | Гибкая компоновка лейаута внутри одного домена |
| Сложность типизации | Средняя (через SlotProps) |
Высокая (интерфейсы slots и slotProps) |
Низкая (базовые интерфейсы React) |
| Влияние на DOM-дерево | Нулевое (новые узлы не создаются) | Минимальное (заменяются штатные узлы) | Создает промежуточные семантические контейнеры |
| Сохранение A11y | Требует внимания при передаче нестандартных детей | Встроено на уровне внутренних хуков | Контролируется структурой Context |
| Сценарий применения | Кнопки, Ссылки, Триггеры дропдаунов | Сложные DataGrid, DatePicker, Схемы | Карточки, Модальные окна, Сложные формы |
MUI X использует словарь слотов:
<DataGrid
slots={{
toolbar: CustomToolbar,
loadingOverlay: CustomLoadingSpinner,
}}
slotProps={{
toolbar: { showQuickFilter: true },
}}
/>
Этот подход оптимален для сложных компонентов-виджетов (таблицы, календари), тогда как asChild лучше подходит для атомарных элементов базового UI-kit.
Рекомендации по интеграции в корпоративную дизайн-систему
- Не усложняйте без необходимости. Если компоненту требуется только изменить текст или иконку слева, обычного пропса
icon?: ReactNodeболее чем достаточно. Паттерн слотов черезSlotили Compound Components необходим там, где вероятны непредсказуемые сценарии расширения. - Контролируйте доступность (a11y). Если слот заменяет интерактивный элемент (например, триггер модального окна), переданный дочерний элемент обязан сохранить семантику (
role="button",tabIndex={0}) и уметь приниматьaria-expanded,aria-controlsи события клавиатуры (onKeyDown). При использовании собственного компонентаSlotпропсы доступности объединяются автоматически. - Строгая типизация слотов. Избегайте использования типа
any. Для именованных слотов указывайтеReact.ReactNode. Если слот ожидает компонент с жестким контрактом, типизируйте его черезReact.ComponentType<CustomProps>. - Устраняйте лишние
div. Использование полиморфногоSlotпозволяет стилизовать ссылки Next.js (Link) или React Router стилями кнопок дизайн-системы без рендеринга невалидной разметки вида<a><button>...</button></a>.
Часто задаваемые вопросы (FAQ)
Чем Slot Pattern принципиально отличается от передачи компонента через Render Prop?
Render Prop передает функцию, которая возвращает разметку и может принимать внутреннее состояние компонента в качестве аргументов (например, children={({ isOpen }) => <div />}). Slot Pattern фокусируется на компоновке структуры: он принимает уже готовый React-элемент или узел (ReactNode) либо объединяет свойства родителя с потомком (как в asChild), не требуя промежуточной функции рендеринга.
Не ухудшает ли использование слотов производительность из-за повторных ререндеров?
Нет. Использование ReactNode в пропсах или составных компонентов работает так же, как обычный props.children. Обновление состояния внутри родительского контейнера вызывает рендеринг только тех слотов, чьи пропсы изменились. Единственный нюанс — создание новых объектов в cloneElement, однако вычислительные накладные расходы здесь несоизмеримо малы по сравнению с перерисовкой DOM.
Как типизировать слот, если в него разрешено передавать только определенный компонент?
В React нельзя на 100% ограничить тип переданного JSX на этапе компиляции без потери гибкости, так как любой компонент в конечном итоге возвращает ReactElement. Однако можно потребовать передачу функции-компонента с жестко заданным интерфейсом пропсов через React.ComponentType<SpecificProps> или использовать дискриминантные объединения (Discriminated Unions) на уровне пропсов родителя.
Что делать с accessibility, если разработчик передает в слот неподходящий тег?
Если внутренний механизм рассчитывает на интерактивный узел (например, button), а разработчик через asChild передает div, компонент потеряет доступность с клавиатуры. Для решения этой проблемы базовый компонент должен принудительно пробрасывать role="button" и tabIndex={0}, а Slot — объединять эти атрибуты с пропсами дочернего элемента.
Когда лучше остановиться на обычном children, а не проектировать слоты?
Если компонент представляет собой простой контейнер с одной зоной ответственности (например, Container, Paper, Typography), props.children — единственно верное решение. Слоты нужны только при наличии нескольких независимых визуальных зон или необходимости полиморфной замены корневого узла без создания оберток.
Заключение
Slot Pattern переносит фокус архитектуры дизайн-системы с конфигурирования через бесконечные пропсы на композицию независимых элементов. Это снижает связность кода, предотвращает проблему prop-drilling и исключает появление лишних контейнеров в DOM.
Использование именованных слотов для лейаутов и утилиты Slot (паттерн asChild) для атомарных компонентов дает гибкую кодовую базу, готовую к масштабированию без постоянного рефакторинга базовых компонентов UI-kit.




.svg.webp)




