Паттерн compound-компонентов
Compound-компоненты — это родитель плюс под-компоненты, неявно разделяющие состояние через контекст: родитель владеет поведением, потребитель располагает части. Окупаются для перекомпонуемых виджетов дизайн-системы, но оверхед для фиксированного компонента из двух элементов.
Ты строишь виджет Tabs для дизайн-системы. Наивный API принимает массив tabs и колбэк renderPanel, и каждая команда, которая его берёт, тут же просит ту единственную вещь, которую он не умеет: «можно поставить бейдж рядом со второй вкладкой?», «можно, чтобы на мобильном панели рендерились перед лентой вкладок?», «можно вставить тултип между двумя вкладками?». Каждый запрос становится новым пропсом, и компонент дрейфует к языку конфигурации, который никому не в радость.
Паттерн compound-компонентов переворачивает владение. Родитель держит поведение — какая вкладка активна, как стрелки двигают фокус, ARIA-обвязку, — и отдаёт раскладку обратно потребителю как обычный JSX, который тот располагает как хочет. Tabs, Tabs.List, Tabs.Tab, Tabs.Panel общаются друг с другом через неявно разделяемое состояние, а вызывающий составляет их как HTML.
После этого урока ты можешь построить compound-компонент — родитель, владеющий состоянием, плюс под-компоненты, читающие его через контекст, — и выставить его как под-поля Parent.Child; объяснить, почему это меняет более богатый API на свободу раскладки у потребителя; распознать, что он блистает для переиспользуемых виджетов дизайн-системы, чьи внутренности перекомпоновываются под каждый экран; и назвать его режим отказа — фиксированный компонент из двух элементов, который никогда не будут перекомпоновывать, где простой компонент на пропсах строго лучше.
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 каждой из них.
Неявный канал между родителем и частями — это 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 в чёткое нарушение контракта прямо в месте вызова.
Под-компоненты прикреплены как статические поля на родителе — 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 от потребителя — он выводит его из разделяемого состояния. Этот вывод, живущий внутри части, и даёт вызывающему свободу раскладки: он ставит вкладку куда угодно, а она всё равно знает, выбрана ли она.
Выигрыш — эргономика потребителя: вызывающий свободно перекомпоновывает внутренности, потому что части находят своё состояние по позиции в дереве, а не по прокинутым пропсам. Те же 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.