Пошук уроків, статей та іншого контенту
Створюйте Compound Components із єдиним API, спільним станом і гнучкою композицією дочірніх компонентів.
Compound Components — це група пов’язаних React-компонентів, які працюють як єдиний компонентний API та використовують спільний стан.
Типові приклади:
Tabs, Tabs.List, Tabs.Tab, Tabs.Panel;
Select, Select.Trigger, Select.Options, Select.Option;
Accordion, Accordion.Item, Accordion.Trigger, Accordion.Panel;
Menu, Menu.Button, Menu.List, Menu.Item.
Замість великої кількості пропсів батьківський компонент надає дочірнім компонентам спільний контекст, а користувач компонента визначає потрібну структуру JSX.
Уявімо звичайний компонент акордеона:
<Accordion
items={[
{ title: "Питання 1", content: "Відповідь 1" },
{ title: "Питання 2", content: "Відповідь 2" },
]}
/>Такий API простий, але недостатньо гнучкий. Ми не можемо легко:
додати іконку до заголовка;
використати складну розмітку всередині панелі;
змінити порядок або структуру дочірніх елементів;
передати власний компонент для окремої частини;
додати додаткові елементи між пунктами.
Compound Components дозволяють описати структуру декларативно:
<Accordion>
<Accordion.Item id="html">
<Accordion.Trigger>
Що таке HTML?
</Accordion.Trigger>
<Accordion.Panel>
<p>HTML описує структуру вебсторінки.</p>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="css">
<Accordion.Trigger>
Що таке CSS?
</Accordion.Trigger>
<Accordion.Panel>
<p>CSS відповідає за стилізацію вебсторінки.</p>
</Accordion.Panel>
</Accordion.Item>
</Accordion>Батьківський Accordion керує станом, але його дочірні компоненти самостійно отримують потрібні дані через Context API.
Compound Component зазвичай складається з таких частин:
Кореневий компонент — зберігає спільний стан.
React Context — передає стан і функції дочірнім компонентам.
Вкладені компоненти — використовують контекст і реалізують окремі частини API.
Композиція через JSX — користувач компонента сам визначає структуру.
Для акордеона це може бути:
Accordion — зберігає активний пункт;
Accordion.Item — визначає окремий пункт;
Accordion.Trigger — перемикає пункт;
Accordion.Panel — відображає вміст пункту.
Створимо контекст для всього акордеона:
const AccordionContext = createContext(null);Контекст міститиме:
ідентифікатор відкритого пункту;
функцію перемикання пункту.
function Accordion({ children, defaultOpenId = null }) {
const [openId, setOpenId] = useState(defaultOpenId);
function toggleItem(id) {
setOpenId((currentId) => {
return currentId === id ? null : id;
});
}
const value = {
openId,
toggleItem,
};
return (
<AccordionContext.Provider value={value}>
<div>{children}</div>
</AccordionContext.Provider>
);
}Accordion не знає, які саме дочірні компоненти містяться всередині. Він лише надає їм спільний стан.
У цьому прикладі одночасно може бути відкритий лише один пункт:
якщо натиснути на закритий пункт — він відкриється;
якщо натиснути на відкритий пункт — він закриється;
якщо відкрити інший пункт — попередній закриється.
Accordion.Item має знати власний id, щоб визначити, чи він відкритий.
Для цього створимо окремий контекст:
const AccordionItemContext = createContext(null);Реалізація Accordion.Item:
function AccordionItem({ id, children }) {
const accordion = useContext(AccordionContext);
if (!accordion) {
throw new Error("Accordion.Item має бути всередині Accordion");
}
const { openId } = accordion;
const isOpen = openId === id;
const value = {
id,
isOpen,
};
return (
<AccordionItemContext.Provider value={value}>
<section data-open={isOpen}>
{children}
</section>
</AccordionItemContext.Provider>
);
}Accordion.Item створює локальний контекст для своїх дочірніх компонентів. Тепер Accordion.Trigger і Accordion.Panel можуть отримати:
id поточного пункту;
значення isOpen.
Accordion.Trigger відповідає за перемикання стану:
function AccordionTrigger({ children }) {
const accordion = useContext(AccordionContext);
const item = useContext(AccordionItemContext);
if (!accordion) {
throw new Error("Accordion.Trigger має бути всередині Accordion");
}
if (!item) {
throw new Error("Accordion.Trigger має бути всередині Accordion.Item");
}
const { toggleItem } = accordion;
const { id, isOpen } = item;
const buttonId = `accordion-trigger-${id}`;
const panelId = `accordion-panel-${id}`;
return (
<h3>
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggleItem(id)}
>
{children}
</button>
</h3>
);
}Важливо, що Trigger не зберігає власний стан. Він використовує стан Accordion.
Завдяки цьому джерело істини єдине: якщо стан змінився в Accordion, усі дочірні компоненти отримають актуальні дані.
Accordion.Panel показує або приховує вміст:
function AccordionPanel({ children }) {
const item = useContext(AccordionItemContext);
if (!item) {
throw new Error("Accordion.Panel має бути всередині Accordion.Item");
}
const { id, isOpen } = item;
const buttonId = `accordion-trigger-${id}`;
const panelId = `accordion-panel-${id}`;
return (
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
hidden={!isOpen}
>
{children}
</div>
);
}Атрибут hidden:
приховує панель, коли isOpen === false;
залишає вміст у DOM;
може бути корисним, якщо стан вмісту має зберігатися між відкриттями.
Тепер потрібно об’єднати всі компоненти під одним об’єктом Accordion:
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Panel = AccordionPanel;Після цього користувач отримує єдиний API:
<Accordion>
<Accordion.Item id="first">
<Accordion.Trigger>Перший пункт</Accordion.Trigger>
<Accordion.Panel>Вміст першого пункту</Accordion.Panel>
</Accordion.Item>
</Accordion>Повний приклад:
import { createContext, useContext, useState } from "react";
const AccordionContext = createContext(null);
const AccordionItemContext = createContext(null);
function Accordion({ children, defaultOpenId = null }) {
const [openId, setOpenId] = useState(defaultOpenId);
function toggleItem(id) {
setOpenId((currentId) => {
return currentId === id ? null : id;
});
}
const value = {
openId,
toggleItem,
};
return (
<AccordionContext.Provider value={value}>
<div>{children}</div>
</AccordionContext.Provider>
);
}
function AccordionItem({ id, children }) {
const accordion = useContext(AccordionContext);
if (!accordion) {
throw new Error("Accordion.Item має бути всередині Accordion");
}
const isOpen = accordion.openId === id;
return (
<AccordionItemContext.Provider value={{ id, isOpen }}>
<section data-open={isOpen}>{children}</section>
</AccordionItemContext.Provider>
);
}
function AccordionTrigger({ children }) {
const accordion = useContext(AccordionContext);
const item = useContext(AccordionItemContext);
if (!accordion) {
throw new Error("Accordion.Trigger має бути всередині Accordion");
}
if (!item) {
throw new Error("Accordion.Trigger має бути всередині Accordion.Item");
}
const { id, isOpen } = item;
return (
<h3>
<button
id={`accordion-trigger-${id}`}
type="button"
aria-expanded={isOpen}
aria-controls={`accordion-panel-${id}`}
onClick={() => accordion.toggleItem(id)}
>
{children}
</button>
</h3>
);
}
function AccordionPanel({ children }) {
const item = useContext(AccordionItemContext);
if (!item) {
throw new Error("Accordion.Panel має бути всередині Accordion.Item");
}
const { id, isOpen } = item;
return (
<div
id={`accordion-panel-${id}`}
role="region"
aria-labelledby={`accordion-trigger-${id}`}
hidden={!isOpen}
>
{children}
</div>
);
}
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Panel = AccordionPanel;
export default function App() {
return (
<main>
<h1>Поширені запитання</h1>
<Accordion defaultOpenId="react">
<Accordion.Item id="html">
<Accordion.Trigger>Що таке HTML?</Accordion.Trigger>
<Accordion.Panel>
<p>
HTML описує структуру та семантику вмісту вебсторінки.
</p>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="css">
<Accordion.Trigger>Що таке CSS?</Accordion.Trigger>
<Accordion.Panel>
<p>
CSS використовується для стилізації та компонування
вебсторінок.
</p>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="react">
<Accordion.Trigger>Що таке React?</Accordion.Trigger>
<Accordion.Panel>
<p>
React — бібліотека для створення користувацьких інтерфейсів.
</p>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</main>
);
}Такий файл можна використовувати як компонент у звичайному React-проєкті. Для запуску достатньо підключити App до стандартної точки входу React-застосунку.
Compound Components не вимагають жорстко заданої структури між дочірніми елементами.
Наприклад, користувач може додати опис, іконку або службовий текст:
<Accordion.Item id="react">
<Accordion.Trigger>
<span>Що таке React?</span>
<span aria-hidden="true">+</span>
</Accordion.Trigger>
<p>Бібліотека для створення інтерфейсів.</p>
<Accordion.Panel>
<article>
<p>
React допомагає створювати інтерфейси з незалежних компонентів.
</p>
</article>
</Accordion.Panel>
</Accordion.Item>Accordion не потрібно змінювати для кожного нового варіанта розмітки.
Саме це є головною перевагою патерну: компонент надає поведінку, але не нав’язує всю структуру інтерфейсу.
Компоненти можна комбінувати з власними компонентами:
function QuestionMeta({ children }) {
return <small>{children}</small>;
}
function App() {
return (
<Accordion>
<Accordion.Item id="react">
<Accordion.Trigger>
Що таке React?
</Accordion.Trigger>
<QuestionMeta>Основи фронтенду</QuestionMeta>
<Accordion.Panel>
<p>React використовується для створення інтерфейсів.</p>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}Батьківський компонент не аналізує дочірні елементи через children і не залежить від їхнього типу. Він просто надає контекст.
Це робить API стійкішим до змін розмітки.
Без Context довелося б передавати стан через кожен рівень компонентів:
<Accordion
openId={openId}
onToggle={toggleItem}
>
<AccordionItem
id="react"
openId={openId}
onToggle={toggleItem}
>
<AccordionTrigger
id="react"
isOpen={isOpen}
onToggle={toggleItem}
/>
</AccordionItem>
</Accordion>Це створює такі проблеми:
багато повторюваних пропсів;
складні сигнатури компонентів;
prop drilling;
сильний зв’язок між внутрішньою структурою та API.
Context дозволяє передати стан безпосередньо компонентам, яким він потрібен:
const accordion = useContext(AccordionContext);
const item = useContext(AccordionItemContext);При цьому стан усе одно залишається централізованим у кореневому компоненті.
Патерн добре підходить, якщо:
кілька компонентів повинні працювати зі спільним станом;
користувачам потрібна гнучка структура JSX;
частини компонента мають логічний зв’язок;
один компонент має кілька взаємопов’язаних підкомпонентів;
набір пропсів для одного великого компонента стає надто складним.
Наприклад, для простого повідомлення достатньо такого API:
<Alert title="Помилка" message="Спробуйте ще раз" />Але для складного меню з кнопкою, списком, пунктами та додатковою розміткою Compound Components можуть бути зручнішими:
<Menu>
<Menu.Button>Дії</Menu.Button>
<Menu.List>
<Menu.Item>Редагувати</Menu.Item>
<Menu.Item>Видалити</Menu.Item>
</Menu.List>
</Menu>У прикладі Accordion використовує некерований стан:
<Accordion defaultOpenId="react">
{/* ... */}
</Accordion>defaultOpenId задає початковий відкритий пункт, а потім стан змінюється всередині Accordion.
У складніших компонентах може знадобитися керований режим, коли стан зберігається у батьківському компоненті. Тоді API може мати такі пропси:
<Accordion
openId={openId}
onOpenChange={setOpenId}
>
{/* ... */}
</Accordion>У такому режимі Accordion не є власником стану, а отримує його через пропси.
Важливо не змішувати обидва підходи без чіткої логіки. Компонент має передбачувано працювати або в некерованому режимі, або в керованому, або мати явно визначені правила для підтримки обох режимів.
Невдалий підхід:
function AccordionItem() {
const [isOpen, setIsOpen] = useState(false);
// ...
}Якщо кожен пункт зберігає власний стан, Accordion не може централізовано керувати поведінкою:
складніше закривати попередній пункт;
важче підтримувати керований режим;
стан може дублюватися.
Спільний стан краще зберігати в Accordion.
Accordion.Trigger має сенс лише всередині Accordion.Item і Accordion.
Тому перевірка контексту допомагає швидко знайти помилку:
if (!item) {
throw new Error("Accordion.Trigger має бути всередині Accordion.Item");
}Без такої перевірки помилка може проявитися як незрозумілий доступ до null або undefined.
cloneElementІноді Compound Components реалізують через обхід children і cloneElement, щоб передати дочірнім елементам пропси.
Такий підхід може бути крихким:
дочірній компонент повинен мати очікуваний тип;
вкладені обгортки можуть порушити логіку;
структура children стає частиною внутрішнього контракту;
складніше підтримувати довільну композицію.
Context краще підходить, коли компонентам потрібно отримувати спільний стан незалежно від кількості обгорток.
Кожен Accordion.Item повинен мати унікальний id:
<Accordion.Item id="html">
{/* ... */}
</Accordion.Item>Цей ідентифікатор використовується для:
визначення активного пункту;
зв’язку кнопки з панеллю;
значень aria-controls і aria-labelledby.
Якщо два пункти матимуть однаковий id, їхня поведінка буде некоректною.
Інтерактивні частини акордеона повинні бути доступними:
для перемикання використовуйте button, а не звичайний div;
додавайте aria-expanded;
пов’язуйте кнопку з панеллю через aria-controls;
використовуйте role="region" і aria-labelledby, якщо це відповідає структурі компонента.
Патерн композиції не повинен погіршувати доступність готового інтерфейсу.
Compound Components — це патерн для створення групи взаємопов’язаних компонентів із єдиним API.
Ключові ідеї:
кореневий компонент зберігає спільний стан;
React Context передає стан дочірнім компонентам;
підкомпоненти доступні через властивості кореневого компонента;
користувач сам визначає структуру JSX;
дочірні компоненти отримують поведінку без prop drilling;
перевірки контексту допомагають виявляти неправильне використання;
доступність і унікальні ідентифікатори потрібно враховувати під час реалізації.
Такий підхід особливо корисний для вкладок, акордеонів, меню, списків вибору та інших компонентів, частини яких повинні координуватися між собою.