Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Slot Pattern в дизайн-системах: проектируем гибкие React-компоненты без prop-drilling

Тимофей Ищенко

Коротко: Разбираем реализацию 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 в цикле приводит к проблемам:

  1. Оборачивание дочернего элемента в HOC (High-Order Component), React.memo или кастомный провайдер ломает проверку child.type.
  2. Фрагменты (<React.Fragment> или <>...</>) не разворачиваются автоматически.
  3. Код становится хрупким, трудно типизируемым и скрывает реальный поток данных.

Реализация паттерна слотов: от простого к продвинутому

Существует три основных способа интеграции слотов в кодовую базу. Выбор зависит от требований к изоляции стилей и гибкости разметки.

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, который берет пропсы родителя и аккуратно объединяет их с пропсами дочернего элемента.

При объединении важно соблюдать правила:

  1. Обработчики событий: должны вызываться оба обработчика (и переданный в Slot, и определенный на дочернем элементе). При этом дочерний обработчик имеет приоритет (если вызван event.defaultPrevented, родительский может не выполняться).
  2. Классы (className): объединяются через конкатенацию.
  3. Стили (style): объединяются через spread-оператор (дочерние переопределяют родительские).
  4. Ссылки (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.


Рекомендации по интеграции в корпоративную дизайн-систему

  1. Не усложняйте без необходимости. Если компоненту требуется только изменить текст или иконку слева, обычного пропса icon?: ReactNode более чем достаточно. Паттерн слотов через Slot или Compound Components необходим там, где вероятны непредсказуемые сценарии расширения.
  2. Контролируйте доступность (a11y). Если слот заменяет интерактивный элемент (например, триггер модального окна), переданный дочерний элемент обязан сохранить семантику (role="button", tabIndex={0}) и уметь принимать aria-expanded, aria-controls и события клавиатуры (onKeyDown). При использовании собственного компонента Slot пропсы доступности объединяются автоматически.
  3. Строгая типизация слотов. Избегайте использования типа any. Для именованных слотов указывайте React.ReactNode. Если слот ожидает компонент с жестким контрактом, типизируйте его через React.ComponentType<CustomProps>.
  4. Устраняйте лишние 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.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также