Паттерн Compound Components в React: когда он упрощает API, а когда только маскирует сложность

Разбираем Compound Components в React на примере Tabs и Accordion: архитектура, Context, controlled/uncontrolled API, производительность, типовые ошибки и ответы для интервью.

16 марта 2026 г.18 минLexicon Team

Введение

Паттерн 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.

Когда паттерн действительно уместен

Паттерн оправдан, когда одновременно выполняются три условия:

  1. У виджета есть несколько устойчивых ролей: список, триггер, панель, оверлей, описание, футер.
  2. Эти роли логически связаны одним состоянием.
  3. Потребителю важна гибкая композиция, а не только передача массива данных.

Типовые хорошие кейсы:

  • Tabs
  • Accordion
  • Menu
  • Dialog
  • Select
  • FormField с Label, Hint, Error

Плохие кейсы:

  • простой Button с двумя-тремя пропсами;
  • карточка, где нет общей координации между частями;
  • компонент, который проще выразить функцией items.map(...).

Если у виджета нет общей модели поведения, compound API будет декоративным слоем без пользы.

Архитектура паттерна: роли, поток данных и точки отказа

Контекст задачи

Представим Tabs. Нам нужно синхронизировать три вещи:

  • текущее активное значение;
  • реакцию на клик и клавиатуру;
  • соответствие между Trigger и Content.

Через плоские пропсы это быстро становится неудобно: родитель начинает знать слишком много о структуре дочерних элементов, а дочерние элементы получают лишние пропсы ради "прокидывания" поведения.

Схема компонентов

Минимальная схема такая:

  • Tabs хранит источник истины и режим работы: controlled или uncontrolled.
  • Tabs.List описывает контейнер для триггеров.
  • Tabs.Trigger читает активное значение и умеет его менять.
  • Tabs.Content решает, нужно ли показывать панель.

Поток управления

Сценарий клика по вкладке:

  1. Tabs.Trigger получает событие.
  2. Он вызывает onValueChange из общего контракта.
  3. Родитель Tabs либо обновляет внутреннее состояние, либо делегирует управление наружу.
  4. Все заинтересованные подкомпоненты получают новое значение.
  5. Виджет синхронно перестраивает активный триггер и панель.

Границы ответственности

  • Корневой компонент отвечает за модель состояния и инварианты.
  • Подкомпоненты отвечают только за свою роль.
  • Внешний код отвечает за композицию и стили.

Если эти границы размыты, паттерн начинает ломаться. Например, когда 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Один компонент с propsRender propsHeadless 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 выглядит "красиво", значит и работает эффективно. На практике узкое место почти всегда не в самом паттерне, а в конкретной реализации.

На что смотреть в профайлере:

  1. Как часто пересоздается значение Context.
  2. Сколько подкомпонентов подписано на него без разбора.
  3. Есть ли тяжелые вычисления в каждом Trigger или Content.
  4. Привязан ли визуальный слой к часто меняющемуся состоянию.

Типичный компромисс:

  • для 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, не проверив профиль рендеров.

Как отвечать на интервью

Сильный ответ обычно строится так:

  1. Compound Components это паттерн проектирования API, а не просто способ разбить компонент на файлы.
  2. Он полезен, когда виджет состоит из нескольких ролей с общей моделью поведения: Tabs, Accordion, Dialog, Select.
  3. Чаще всего состояние держит корневой компонент, а дочерние части получают доступ к нему через Context.
  4. Главное преимущество: JSX отражает структуру интерфейса и не раздувает API пропсами.
  5. Главные риски: неявная связность, лишние ре-рендеры, слабая доступность и неясный 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

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