open atlas
↑ К треку
Паттерны React RXP · 02 · 01

Паттерн compound-компонентов

Compound-компоненты — это родитель плюс под-компоненты, неявно разделяющие состояние через контекст: родитель владеет поведением, потребитель располагает части. Окупаются для перекомпонуемых виджетов дизайн-системы, но оверхед для фиксированного компонента из двух элементов.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ты строишь виджет Tabs для дизайн-системы. Наивный API принимает массив tabs и колбэк renderPanel, и каждая команда, которая его берёт, тут же просит ту единственную вещь, которую он не умеет: «можно поставить бейдж рядом со второй вкладкой?», «можно, чтобы на мобильном панели рендерились перед лентой вкладок?», «можно вставить тултип между двумя вкладками?». Каждый запрос становится новым пропсом, и компонент дрейфует к языку конфигурации, который никому не в радость.

Паттерн compound-компонентов переворачивает владение. Родитель держит поведение — какая вкладка активна, как стрелки двигают фокус, ARIA-обвязку, — и отдаёт раскладку обратно потребителю как обычный JSX, который тот располагает как хочет. Tabs, Tabs.List, Tabs.Tab, Tabs.Panel общаются друг с другом через неявно разделяемое состояние, а вызывающий составляет их как HTML.

Цель

После этого урока ты можешь построить compound-компонент — родитель, владеющий состоянием, плюс под-компоненты, читающие его через контекст, — и выставить его как под-поля Parent.Child; объяснить, почему это меняет более богатый API на свободу раскладки у потребителя; распознать, что он блистает для переиспользуемых виджетов дизайн-системы, чьи внутренности перекомпоновываются под каждый экран; и назвать его режим отказа — фиксированный компонент из двух элементов, который никогда не будут перекомпоновывать, где простой компонент на пропсах строго лучше.

1

Compound-компонент — это один родитель, владеющий состоянием, плюс под-компоненты, читающие его неявно: потребитель никогда не прокидывает состояние вручную. Сравни две формы API. Tabs на пропсах принимает массив данных и диктует разметку; compound-Tabs принимает children и позволяет вызывающему написать разметку, пока части молча разделяют, какая вкладка активна.

// props-driven: the component owns the layout, you feed it config
<Tabs
  tabs={[{ id: "a", label: "Account", panel: <Account /> }]}
  renderTab={(t) => <span>{t.label}</span>}
/>

// compound: you own the layout, the pieces share state implicitly
<Tabs defaultValue="account">
  <Tabs.List>
    <Tabs.Tab value="account">Account</Tabs.Tab>
    <Tabs.Tab value="billing">Billing</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="account"><Account /></Tabs.Panel>
  <Tabs.Panel value="billing"><Billing /></Tabs.Panel>
</Tabs>

Compound-версия читается как разметка, а не конфигурация. Эта читаемость и есть фича — и берётся она из того, что части разделяют состояние, без передачи вызывающим activeTab каждой из них.

2

Неявный канал между родителем и частями — это React-контекст: родитель предоставляет, каждый под-компонент потребляет. Родитель держит активное значение в состоянии и выставляет его (плюс сеттер) через контекст. Tabs.Tab читает контекст, чтобы знать, выбран ли он, и переключить значение по клику; Tabs.Panel читает его, чтобы решить, рендериться ли. Никакого проброса пропсов, никакого клея от вызывающего.

type TabsCtx = { value: string; setValue: (v: string) => void };
const TabsContext = createContext<TabsCtx | null>(null);

function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs.* must be rendered inside <Tabs>");
  return ctx;
}

function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) {
  const [value, setValue] = useState(defaultValue);
  return <TabsContext.Provider value={{ value, setValue }}>{children}</TabsContext.Provider>;
}

Защита useTabs, которая бросает исключение при использовании вне провайдера, — не опциональный лоск: она превращает запутанный крах с null в чёткое нарушение контракта прямо в месте вызова.

3

Под-компоненты прикреплены как статические поля на родителе — Tabs.List, Tabs.Tab, Tabs.Panel — так что API приходит одним импортом. Каждая часть — обычный компонент, вызывающий useTabs(). Подвешивание их к родителю группирует пространство имён семейства, сигнализирует об их принадлежности друг к другу и означает, что потребители импортируют один символ.

Tabs.List = function List({ children }: { children: React.ReactNode }) {
  return <div role="tablist">{children}</div>;
};

Tabs.Tab = function Tab({ value, children }: { value: string; children: React.ReactNode }) {
  const { value: active, setValue } = useTabs();
  const selected = active === value;
  return (
    <button role="tab" aria-selected={selected} onClick={() => setValue(value)}>
      {children}
    </button>
  );
};

Tabs.Panel = function Panel({ value, children }: { value: string; children: React.ReactNode }) {
  const { value: active } = useTabs();
  return active === value ? <div role="tabpanel">{children}</div> : null;
};

Tabs.Tab не получает isActive от потребителя — он выводит его из разделяемого состояния. Этот вывод, живущий внутри части, и даёт вызывающему свободу раскладки: он ставит вкладку куда угодно, а она всё равно знает, выбрана ли она.

4

Выигрыш — эргономика потребителя: вызывающий свободно перекомпоновывает внутренности, потому что части находят своё состояние по позиции в дереве, а не по прокинутым пропсам. Те же Tabs рендерят панели над лентой на мобильном, всовывают бейдж в подпись вкладки или бросают разделитель между вкладками — ничего из этого родителю не пришлось предвидеть. API на пропсах потребовал бы нового пропса под каждое из этих; compound-API не требует ничего, потому что раскладка — забота потребителя, а поведение — родителя.

// the consumer reshuffles internals — the parent never changed
<Tabs defaultValue="inbox">
  <Tabs.Panel value="inbox"><Inbox /></Tabs.Panel>   {/* panels first on mobile */}
  <Tabs.List>
    <Tabs.Tab value="inbox">Inbox <UnreadBadge /></Tabs.Tab>  {/* badge inline */}
    <Divider />                                              {/* arbitrary node */}
    <Tabs.Tab value="archive">Archive</Tabs.Tab>
  </Tabs.List>
</Tabs>

Именно поэтому дизайн-системы (Radix, Reach, Headless UI, Ariakit) поставляют compound-API: библиотека владеет сложным, инвариантным поведением; приложение владеет раскладкой, которая отличается на каждом экране.

Разбор примера

От тяжёлого на конфиге API на пропсах к compound — и момент, когда это перестаёт окупаться. Команда выпускает Accordion как компонент на пропсах:

// before: every layout need becomes a prop
<Accordion
  items={[{ id: "1", title: "Shipping", body: <Shipping /> }]}
  renderTitle={(item, open) => <span>{item.title} {open ? "▲" : "▼"}</span>}
  allowMultiple
  defaultOpenIds={["1"]}
/>

Три экрана спустя список пропсов оброс renderIcon, titleClassName, headerSlot, dividerBetweenItems — каждый добавлен потому, что одному экрану понадобилось расположить внутренности иначе. Это разрастание пропсов и есть сигнал переходить на compound:

// after: the parent owns open-state; the consumer arranges everything else
<Accordion defaultOpen={["shipping"]} allowMultiple>
  <Accordion.Item value="shipping">
    <Accordion.Header>Shipping <Chevron /></Accordion.Header>
    <Accordion.Body><Shipping /></Accordion.Body>
  </Accordion.Item>
  <Divider />
  <Accordion.Item value="returns">
    <Accordion.Header>Returns</Accordion.Header>
    <Accordion.Body><Returns /></Accordion.Body>
  </Accordion.Item>
</Accordion>

Accordion всё ещё владеет тем, какие элементы открыты, и поведением toggle/ARIA; разметка заголовка, шеврон, разделитель, правки под экран — теперь обычный JSX. API на пропсах потребовал бы нового пропса под каждое; compound-API впитывает их бесплатно.

Теперь режим отказа. Положим, вместо аккордеона у тебя карточка Stat, показывающая подпись над значением — два фиксированных элемента, всегда в этом порядке, никогда не перекомпонуемых:

// over-engineered: a compound API for a fixed two-element component
<Stat>
  <Stat.Label>Revenue</Stat.Label>
  <Stat.Value>$2.4M</Stat.Value>
</Stat>

// right-sized: a plain props component, half the code, nothing to misuse
<Stat label="Revenue" value="$2.4M" />

Compound-Stat не покупает ничего — нет раскладки для перекомпоновки, нет поведения для разделения, не грядёт второго расположения, — а стоит контекста, двух под-компонентов и более многословного места вызова, которое читателю приходится собирать в голове. Когда внутренности фиксированы и нет поведения для централизации, простой компонент на пропсах — выбор уровня senior. Compound-компоненты оправдывают свою цену только тогда, когда потребителю действительно нужно располагать части.

Почему это работает

Почему контекст, а не React.Children.map + cloneElement, чтобы внедрить активное состояние в каждого ребёнка? cloneElement дотягивается только до прямых детей, так что в тот момент, когда потребитель оборачивает Tabs.Tab в свой <div> или <Divider> встаёт между вкладками, инъекция его промахивается. Контекст дотягивается до любого потомка на любой глубине — а это ровно та свобода раскладки, которую обещает паттерн. Compound-компоненты на основе cloneElement — известная мина именно по этой причине; контекст — современный дефолт.

Частая ошибка

Соблазнительное неверное прочтение — «compound-компоненты это продвинутый API, значит, в дизайн-системе тянись к ним по умолчанию». Они оправдывают себя только под давлением перекомпоновки — потребителю нужно ставить, переупорядочивать или перемежать внутренности. У виджета фиксированной формы (подписанное значение, чип «аватар-плюс-имя», иконочная кнопка) нет внутренностей, достойных выставления, так что compound-API там — чистый оверхед: больше кода, путь перерисовки через контекст и место вызова, которое читателю приходится пересобирать ради нуля выигранной гибкости. Подбирай паттерн под давление: перекомпонуемые внутренности → compound; фиксированная форма из двух элементов → простые пропсы.

Проверь себя
Викторина

Ты проектируешь переиспользуемый компонент Avatar для дизайн-системы. Он всегда рендерит картинку со статус-точкой в правом нижнем углу — фиксированная форма из двух элементов, никогда не перекомпонуемая, используется одинаково везде. Стоит ли выставлять его как compound-компонент (Avatar + Avatar.Image + Avatar.StatusDot)?

Итог

Compound-компонент — это родитель, владеющий состоянием, плюс под-компоненты, читающие это состояние неявно через контекст, прикреплённые как статические поля (Tabs.List, Tabs.Tab, Tabs.Panel), так что API приходит одним импортом. Разделение и есть суть: родитель владеет поведением (активное значение, фокус, ARIA), потребитель владеет раскладкой, располагая части как обычный JSX. Поскольку каждая часть находит своё состояние по позиции в дереве, а не по прокинутым пропсам, вызывающий может переупорядочивать, перемежать и декорировать внутренности безо всего, что родителю пришлось бы предвидеть, — именно поэтому дизайн-системы выставляют свои виджеты так. Цена — более богатая поверхность (контекст, под-компоненты, защита от неверного использования), так что он окупается только под давлением перекомпоновки. Режим отказа — применение его к фиксированному компоненту из двух элементов, который никогда не будут перекомпоновывать: там простой компонент на пропсах строго лучше — меньше кода, нечего собирать неправильно и не теряется гибкость. Перекомпонуемые внутренности → compound; фиксированная форма → простые пропсы.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.