Паттерн Compound Components в React: когда он упрощает API, а когда только маскирует сложность
Разбираем Compound Components в React на примере Tabs и Accordion: архитектура, Context, controlled/uncontrolled API, производительность, типовые ошибки и ответы для интервью.
- Введение
- Что такое Compound Components и какую проблему они решают
- Когда паттерн действительно уместен
- Архитектура паттерна: роли, поток данных и точки отказа
- Контекст задачи
- Схема компонентов
- Поток управления
- Границы ответственности
- Узкие места
- Проблемный API: "пропсовый комбайн"
- Код-пример 1: базовая реализация Tabs через Compound Components
- Код-пример 2: controlled и uncontrolled режимы без двусмысленности
- Где Compound Components сильнее альтернатив
- Ошибки в production: где паттерн ломается в реальном проекте
- Ошибка 1. Контекст превратился в "God object"
- Ошибка 2. Паттерн завязан на точный порядок детей
- Ошибка 3. Отсутствует доступность
- Ошибка 4. Смешан внешний и внутренний контроль состояния
- Ошибка 5. Императивный escape hatch не продуман
- Разбор производительности: где узкое место на самом деле
- Практики, которые делают Compound Components рабочим инструментом
- 1. Думайте сначала про контракт, потом про синтаксис
- 2. Делайте ошибки явными
- 3. Не смешивайте визуальный API и модель поведения
- 4. Ограничивайте контекст минимумом
- 5. Тестируйте сценарии композиции, а не только отдельные функции
- 6. Не бойтесь отказаться от паттерна
- Частые ошибки
- Как отвечать на интервью
- FAQ
- Что такое Compound Components в React простыми словами?
- Когда Compound Components лучше обычного API через props?
- Нужен ли Context для Compound Components?
- Чем Compound Components отличаются от render props?
- Какая ошибка при реализации встречается чаще всего?
- Итоги
Введение
Паттерн Compound Components в React чаще всего вспоминают на примере Tabs, Accordion, Select или Modal. На собеседовании его обычно описывают одной фразой: "это когда компонент состоит из нескольких подкомпонентов". Этого мало.
Практический смысл паттерна не в красивом термине, а в дизайне API. Если виджет состоит из нескольких ролей, длинный набор пропсов быстро превращается в "суп из конфигурации": items, renderLabel, renderPanel, activeId, onTabClick, orientation, disabledIds. В таких случаях композиция через JSX часто читается лучше, чем плоский объект настроек.
Тема тесно связана с вопросом о том, когда Context API действительно стоит использовать, потому что именно через контекст чаще всего связывают части compound-компонента.
В статье разберем, что именно решает паттерн, как устроить его архитектуру, где он начинает вредить, и как объяснять этот выбор на интервью без общих слов.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Что такое Compound Components и какую проблему они решают
Compound Components, или "составные компоненты", это набор связанных компонентов с общим контрактом. Пользователь API не передает всю структуру через один гигантский объект пропсов, а собирает виджет из частей:
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Профиль</Tabs.Trigger>
<Tabs.Trigger value="security">Безопасность</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile">...</Tabs.Content>
<Tabs.Content value="security">...</Tabs.Content>
</Tabs>
Здесь Tabs, Tabs.List, Tabs.Trigger и Tabs.Content работают как одна система. У них есть общая модель состояния, но внешний API выглядит как естественная структура интерфейса.
Что паттерн дает на практике:
- API ближе к реальной разметке.
- Проще выражать вложенность и роли элементов.
- Легче расширять компонент без взрывного роста числа пропсов.
- Появляется пространство для headless-подхода, когда библиотека управляет поведением, а не внешним видом.
Что паттерн не гарантирует:
- хорошую доступность "из коробки";
- хорошую производительность;
- типобезопасность сложных сценариев;
- понятный контракт, если архитектура не продумана.
То есть compound-компоненты не магия, а способ организовать API вокруг структуры UI.
Когда паттерн действительно уместен
Паттерн оправдан, когда одновременно выполняются три условия:
- У виджета есть несколько устойчивых ролей: список, триггер, панель, оверлей, описание, футер.
- Эти роли логически связаны одним состоянием.
- Потребителю важна гибкая композиция, а не только передача массива данных.
Типовые хорошие кейсы:
TabsAccordionMenuDialogSelectFormFieldсLabel,Hint,Error
Плохие кейсы:
- простой
Buttonс двумя-тремя пропсами; - карточка, где нет общей координации между частями;
- компонент, который проще выразить функцией
items.map(...).
Если у виджета нет общей модели поведения, compound API будет декоративным слоем без пользы.
Архитектура паттерна: роли, поток данных и точки отказа
Контекст задачи
Представим Tabs. Нам нужно синхронизировать три вещи:
- текущее активное значение;
- реакцию на клик и клавиатуру;
- соответствие между
TriggerиContent.
Через плоские пропсы это быстро становится неудобно: родитель начинает знать слишком много о структуре дочерних элементов, а дочерние элементы получают лишние пропсы ради "прокидывания" поведения.
Схема компонентов
Минимальная схема такая:
Tabsхранит источник истины и режим работы: controlled или uncontrolled.Tabs.Listописывает контейнер для триггеров.Tabs.Triggerчитает активное значение и умеет его менять.Tabs.Contentрешает, нужно ли показывать панель.
Поток управления
Сценарий клика по вкладке:
Tabs.Triggerполучает событие.- Он вызывает
onValueChangeиз общего контракта. - Родитель
Tabsлибо обновляет внутреннее состояние, либо делегирует управление наружу. - Все заинтересованные подкомпоненты получают новое значение.
- Виджет синхронно перестраивает активный триггер и панель.
Границы ответственности
- Корневой компонент отвечает за модель состояния и инварианты.
- Подкомпоненты отвечают только за свою роль.
- Внешний код отвечает за композицию и стили.
Если эти границы размыты, паттерн начинает ломаться. Например, когда Tabs.Trigger сам знает слишком много о DOM-соседях или когда Tabs.Content пытается вычислять активную вкладку без общего контракта.
Узкие места
Главные точки отказа у compound-компонентов:
- неявная зависимость от порядка детей;
- перегруженный
Context, который пересоздается на каждый рендер; - отсутствие controlled/uncontrolled режима;
- сломанная доступность: нет
role,aria-selected,aria-controls; - жесткая привязка к конкретной DOM-структуре.
Чтобы понимать, как это отражается на ре-рендерах, полезно держать в голове базовую модель из разбора темы когда React действительно перерисовывает компонент.
Проблемный API: "пропсовый комбайн"
До compound-подхода Tabs часто начинают так:
type TabItem = {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
};
type TabsProps = {
items: TabItem[];
activeId?: string;
defaultActiveId?: string;
onChange?: (id: string) => void;
renderLabel?: (item: TabItem) => React.ReactNode;
renderContent?: (item: TabItem) => React.ReactNode;
orientation?: "horizontal" | "vertical";
className?: string;
};
export function Tabs(props: TabsProps) {
// ...
}
Такой API можно довести до рабочего состояния, но цена растет быстро:
- каждая новая роль добавляет еще пропсы;
- кастомизация уходит в
render*-функции; - разметка перестает быть очевидной;
- потребитель теряет контроль над локальной композицией.
Это не значит, что API через items всегда плох. Для простых дашбордных табов он может быть дешевле и понятнее. Но если компонент должен жить как переиспользуемый building block, compound-подход обычно масштабируется лучше.
Код-пример 1: базовая реализация Tabs через Compound Components
Ниже упрощенная headless-реализация с Context. В ней есть важный минимум: общий контракт, controlled/uncontrolled режим и защита от использования вне Tabs.
import {
createContext,
useContext,
useId,
useMemo,
useState,
type ReactNode,
} from "react";
type TabsContextValue = {
value: string;
setValue: (next: string) => void;
baseId: string;
};
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error("Tabs compound components must be used within <Tabs />");
}
return ctx;
}
type TabsProps = {
value?: string;
defaultValue?: string;
onValueChange?: (next: string) => void;
children: ReactNode;
};
export function Tabs({
value,
defaultValue,
onValueChange,
children,
}: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internalValue;
const baseId = useId();
const contextValue = useMemo<TabsContextValue>(
() => ({
value: currentValue,
setValue: (next) => {
if (!isControlled) setInternalValue(next);
onValueChange?.(next);
},
baseId,
}),
[baseId, currentValue, isControlled, onValueChange]
);
return <TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider>;
}
function List({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Trigger({ value, children }: { value: string; children: ReactNode }) {
const { value: activeValue, setValue, baseId } = useTabsContext();
const isActive = activeValue === value;
return (
<button
type="button"
role="tab"
id={`${baseId}-tab-${value}`}
aria-selected={isActive}
aria-controls={`${baseId}-panel-${value}`}
onClick={() => setValue(value)}
>
{children}
</button>
);
}
function Content({ value, children }: { value: string; children: ReactNode }) {
const { value: activeValue, baseId } = useTabsContext();
const isActive = activeValue === value;
if (!isActive) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${value}`}
aria-labelledby={`${baseId}-tab-${value}`}
>
{children}
</div>
);
}
Tabs.List = List;
Tabs.Trigger = Trigger;
Tabs.Content = Content;
Почему этот вариант лучше "пропсового комбайна":
- структура JSX отражает структуру UI;
- состояние централизовано в одном месте;
- controlled и uncontrolled сценарии не размазаны по детям;
- дочерние части не требуют ручного проброса пропсов через несколько уровней.
Но здесь уже видно и ограничение: любое изменение value обновит всех потребителей контекста. Это нормально для небольших Tabs, но для сложных виджетов нужно внимательнее проектировать подписки и мемоизацию. Сама механика стабилизации Context подробно разбирается в статье о React.memo, useMemo и useCallback на практике.
Код-пример 2: controlled и uncontrolled режимы без двусмысленности
Одна из самых частых production-ошибок в compound-компонентах: компонент вроде бы поддерживает оба режима, но делает это неявно. В результате потребитель случайно переключает виджет из uncontrolled в controlled и получает странное поведение.
Надежнее сразу зафиксировать правила:
type AccordionProps = {
value?: string[];
defaultValue?: string[];
onValueChange?: (next: string[]) => void;
children: React.ReactNode;
};
export function Accordion({
value,
defaultValue,
onValueChange,
children,
}: AccordionProps) {
const [internalValue, setInternalValue] = useState<string[]>(defaultValue ?? []);
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internalValue;
const toggleItem = (item: string) => {
const next = currentValue.includes(item)
? currentValue.filter((x) => x !== item)
: [...currentValue, item];
if (!isControlled) {
setInternalValue(next);
}
onValueChange?.(next);
};
return (
<AccordionContext.Provider value={{ currentValue, toggleItem }}>
{children}
</AccordionContext.Provider>
);
}
Ключевой момент здесь не в синтаксисе, а в контракте:
- если
valueпередан, состояние контролирует внешний код; - если
valueне передан, источник истины внутри компонента; - переход между режимами на лету лучше запретить и явно документировать.
Это тот же класс проблем, что и у controlled/uncontrolled форм в React. Если разработчик понимает эту параллель, ему проще объяснять и compound API, и поведение формы на интервью.
Где Compound Components сильнее альтернатив
Ниже не абстрактное сравнение, а практический выбор API для библиотечного или shared UI-компонента.
| Критерий | Compound Components | Один компонент с props | Render props | Headless hook |
|---|---|---|---|---|
| Читаемость JSX-структуры | Высокая | Средняя | Низкая при сложной вложенности | Низкая, поведение вынесено в код |
| Гибкость композиции | Высокая | Ограниченная контрактом пропсов | Высокая | Очень высокая |
| Стоимость входа для команды | Средняя | Низкая | Средняя | Средняя или высокая |
| Неявная связность частей | Высокая | Низкая | Средняя | Низкая |
| Контроль над производительностью | Средний | Высокий в простом API | Средний | Высокий |
| Удобство для design-system | Высокое | Среднее | Среднее | Высокое для опытной команды |
Как читать эту таблицу:
- Если нужен быстрый и простой компонент, props API часто дешевле.
- Если важна декларативная структура и переиспользование ролей, compound-подход выигрывает.
- Если компонент очень динамический и библиотека должна дать полный контроль, headless hook может быть сильнее.
- Если приходится протаскивать в compound-компонент десятки неявных правил, возможно, вы уже переросли этот уровень абстракции.
Для модальных окон и всплывающих слоев выбор еще сильнее зависит от структуры DOM и позиционирования. В таких сценариях compound-паттерн часто комбинируют с React Portals.
Ошибки в production: где паттерн ломается в реальном проекте
Ошибка 1. Контекст превратился в "God object"
Симптом:
- в
Contextлежат состояние, стили, refs, вычисленные данные, callbacks и флаги на все случаи жизни; - любой чих в корневом компоненте двигает всех потребителей.
Последствие:
- каскадные ре-рендеры;
- сложный профилинг;
- дочерние части зависят от лишнего.
Исправление:
- делить контекст по ответственности;
- стабилизировать
value; - не передавать в него то, что можно вычислить локально.
Ошибка 2. Паттерн завязан на точный порядок детей
Симптом:
Tabs.Contentищет "предыдущий элемент";Accordion.Itemработает только если внутри ровно определенный набор дочерних узлов;- перестановка JSX ломает поведение без явной ошибки.
Последствие:
- API выглядит гибким, но реально хрупок;
- ошибки всплывают только после незначительного рефакторинга.
Исправление:
- опираться на явные
valueи роли, а не на соседей по дереву; - выбрасывать ранние ошибки в development;
- не кодировать бизнес-логику через порядок
children, если без этого можно обойтись.
Ошибка 3. Отсутствует доступность
Симптом:
- визуально табы работают;
- клавиатурная навигация отсутствует;
aria-*атрибуты не расставлены.
Последствие:
- виджет не проходит ревью по accessibility;
- дизайн-система копирует дефект по всему продукту.
Исправление:
- проектировать роли и состояния до стилизации;
- проверить
role,aria-selected,aria-controls,aria-labelledby, фокус-менеджмент; - тестировать клавиатуру отдельно, а не только мышь.
Ошибка 4. Смешан внешний и внутренний контроль состояния
Симптом:
valueиногда идет извне, иногда компонент сам его меняет;onChangeстреляет, но UI не синхронизирован;- баги проявляются только в некоторых интеграциях.
Последствие:
- компонент трудно предсказать;
- в shared UI начинают появляться локальные костыли.
Исправление:
- выбрать явный controlled/uncontrolled контракт;
- документировать приоритеты и дефолты;
- в development предупреждать о смене режима.
Ошибка 5. Императивный escape hatch не продуман
Иногда compound-компоненту нужен императивный канал: открыть Dialog, сфокусировать триггер, прокрутить активный таб. Если этот сценарий вспоминают поздно, API начинает обрастать случайными пропсами и ref-хаком. Для таких случаев полезно заранее понимать, когда уместен forwardRef и useImperativeHandle.
Разбор производительности: где узкое место на самом деле
У compound-компонентов есть типичная иллюзия: API выглядит "красиво", значит и работает эффективно. На практике узкое место почти всегда не в самом паттерне, а в конкретной реализации.
На что смотреть в профайлере:
- Как часто пересоздается значение
Context. - Сколько подкомпонентов подписано на него без разбора.
- Есть ли тяжелые вычисления в каждом
TriggerилиContent. - Привязан ли визуальный слой к часто меняющемуся состоянию.
Типичный компромисс:
- для
Tabsс 5-10 триггерами стоимость общих ре-рендеров почти всегда приемлема; - для сложного
Selectс поиском, виртуализацией и асинхронной загрузкой один общий контекст уже может быть дорогим; - для большого
MenuилиTreeчасто выгоднее выносить часть логики в headless hook или store с селективными подписками.
Когда оптимизация оправдана:
- профилирование показывает заметный commit time;
- виджет используется массово на странице;
- активное состояние меняется часто, например на каждом вводе.
Когда оптимизация преждевременна:
- компонент переключается по клику раз в несколько секунд;
- число детей невелико;
- профилировщик не показывает реальной проблемы.
Если compound-компонент начинает тянуть за собой лишние перерисовки всего экрана, это уже не вопрос паттерна, а вопрос архитектуры состояния. В смежных темах это хорошо видно в материале про оптимизацию React и поиск узких мест.
Прокачай React за 7 дней
20 вопросов и разборов по React Hooks.
Практики, которые делают Compound Components рабочим инструментом
1. Думайте сначала про контракт, потом про синтаксис
Сначала ответьте на вопросы:
- где источник истины;
- какие роли обязательны;
- можно ли собирать части в любом порядке;
- что должно происходить при ошибочном использовании.
Если на эти вопросы нет ответа, красивый JSX не спасет.
2. Делайте ошибки явными
Хук вроде useTabsContext() должен падать с понятным сообщением, если Tabs.Trigger использован вне Tabs. Это дешевый способ убрать тихие баги интеграции.
3. Не смешивайте визуальный API и модель поведения
Хороший compound-компонент обычно headless или близок к headless-модели: состояние и взаимодействие отделены от конкретного CSS. Тогда его проще переносить между проектами и дизайн-темами.
4. Ограничивайте контекст минимумом
Передавайте в Context только то, что реально нужно нескольким частям. Все, что можно вычислить локально, вычисляйте локально.
5. Тестируйте сценарии композиции, а не только отдельные функции
Нужно проверять:
- правильное связывание
TriggerиContent; - controlled и uncontrolled режимы;
- клавиатурную навигацию;
- ошибки неправильного использования.
6. Не бойтесь отказаться от паттерна
Если выяснилось, что компонент проще выразить через items и пару callback-ов, так и делайте. Compound Components полезны только там, где композиция реально снижает сложность API.
Частые ошибки
- Называть compound-компонентом любой компонент со статическими свойствами вроде
Modal.Header. - Использовать
Contextпо умолчанию, даже если достаточно явной передачи пропсов. - Завязывать логику на порядок
childrenвместо явных идентификаторов. - Не поддерживать controlled/uncontrolled режим, хотя компонент используется в shared UI.
- Игнорировать accessibility и считать паттерн чисто "архитектурным".
- Оптимизировать вслепую через
memo, не проверив профиль рендеров.
Как отвечать на интервью
Сильный ответ обычно строится так:
- Compound Components это паттерн проектирования API, а не просто способ разбить компонент на файлы.
- Он полезен, когда виджет состоит из нескольких ролей с общей моделью поведения:
Tabs,Accordion,Dialog,Select. - Чаще всего состояние держит корневой компонент, а дочерние части получают доступ к нему через
Context. - Главное преимущество: JSX отражает структуру интерфейса и не раздувает API пропсами.
- Главные риски: неявная связность, лишние ре-рендеры, слабая доступность и неясный controlled/uncontrolled контракт.
Если хотите показать middle-уровень, добавьте компромисс: "Для простого виджета я не буду насильно строить compound API. Сначала оцениваю, действительно ли композиция делает контракт проще, а не сложнее". Такой ответ звучит сильнее, чем абстрактное "это современный паттерн".
Полезно еще связать тему с другими механизмами React: где нужен Context, как влияют ре-рендеры, и почему часть логики иногда лучше вынести в hook, а не прятать в неявную магию компонента.
Разберите паттерны React в формате реального интервью
Практика по React с разбором API-дизайна, compound components, state management и архитектурных компромиссов без шаблонных ответов.
FAQ
Что такое Compound Components в React простыми словами?
Это набор связанных компонентов, которые работают как один виджет. Корневой компонент хранит общее состояние и правила, а дочерние части описывают роли интерфейса через JSX.
Когда Compound Components лучше обычного API через props?
Когда у виджета много ролей и вложенная композиция важнее массива настроек. Для Tabs, Accordion, Menu, Dialog такой API обычно читается лучше и масштабируется спокойнее.
Нужен ли Context для Compound Components?
Часто нужен, потому что он убирает ручной проброс пропсов между частями. Но это не обязательное правило. В простых случаях можно обойтись более прямым контрактом.
Чем Compound Components отличаются от render props?
Compound-подход делает структуру интерфейса декларативной в JSX. Render props дают больше программного контроля, но быстрее превращают разметку в вложенные функции и шумный API.
Какая ошибка при реализации встречается чаще всего?
Обычно это смесь из трех вещей: перегруженный Context, неявная зависимость от порядка детей и отсутствие четкого controlled/uncontrolled поведения. В результате компонент выглядит гибким, но интегрируется плохо.
Итоги
Паттерн Compound Components в React полезен не сам по себе, а как способ спроектировать API для сложного составного виджета. Его сильная сторона в том, что структура JSX совпадает со структурой интерфейса и не заставляет засовывать все поведение в один список пропсов.
Но у паттерна есть цена: больше неявной связности, выше требования к контракту, доступности и контролю рендеров. Поэтому правильный вопрос не "использовать ли compound-компоненты всегда", а "уменьшают ли они сложность именно в этом виджете". Если ответ положительный, паттерн работает отлично. Если нет, лучше выбрать более прямой API.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Автор
Lexicon Team
Читайте также
frontend
Render Props в React: паттерн, который часто спрашивают на собеседовании
Render Props в React на практике: где паттерн выигрывает у hooks, как устроить контракт, избежать лишних ререндеров и уверенно отвечать на интервью.
frontend
React patterns, которые спрашивают на senior интервью: не определения, а архитектурные компромиссы
Разбираем React patterns для senior интервью: HOC, Render Props, Compound Components, controlled/uncontrolled, headless API и критерии выбора.
frontend
Higher Order Components (HOC) в React: объяснение и примеры без магии
Разбираем Higher Order Components в React: как устроен HOC, где он полезен, чем отличается от hooks и render props, и как объяснить паттерн на интервью.