29. Renderless компоненты
Renderless-компонент (или headless-компонент) — это компонент, который управляет логикой и состоянием, но не рендерит никакой UI. Он предоставляет поведение через children или render prop, позволяя полностью контролировать визуальную часть.
Этот паттерн лежит в основе библиотек вроде Headless UI, Radix UI и @kobalte/core.
Зачем нужен renderless-паттерн?
Заголовок раздела «Зачем нужен renderless-паттерн?»Представь, что тебе нужен аккордеон с одинаковой логикой, но разным визуальным оформлением: минималистичным для одного сайта, карточным для другого, outlined для третьего. Копировать логику в каждый компонент — антипаттерн. Renderless-подход разделяет “что делает” от “как выглядит”:
┌─────────────────────────────────┐│ <Accordion> │ ← управляет открытыми панелями│ поведение: открыть/закрыть │ ← предоставляет isOpen, toggle│ accessibility: ARIA │ ← добавляет role, aria-expanded│ keyboard nav: Enter/Space │ ← слушает keydown│ ││ НЕТ стилей, НЕТ HTML-структуры │└─────────────────────────────────┘ ↓ передаёт через children┌─────────────────────────────────┐│ Визуальный компонент │ ← ты пишешь сам│ (<MinimalAccordion> / │ ← любые стили│ <CardAccordion> / │ ← любая HTML-структура│ <OutlinedAccordion>) │└─────────────────────────────────┘Children as render prop в Solid.js
Заголовок раздела «Children as render prop в Solid.js»В Solid.js children может быть функцией, которая получает данные от родителя:
// Renderless компонентinterface AccordionProps { children: (state: { isOpen: boolean; toggle: () => void; }) => JSXElement;}
function Accordion(props: AccordionProps) { const [isOpen, setIsOpen] = createSignal(false);
// Передаём состояние и методы через children-как-функцию return ( <>{props.children({ isOpen: isOpen(), toggle: () => setIsOpen(o => !o) })}</> );}
// Потребитель — полный контроль над UIfunction MyPage() { return ( <Accordion> {({ isOpen, toggle }) => ( <div> <button onClick={toggle}>{isOpen ? '▲' : '▼'} Раздел</button> <Show when={isOpen}> <p>Содержимое раздела</p> </Show> </div> )} </Accordion> );}Context-based renderless
Заголовок раздела «Context-based renderless»Для более сложных компонентов с вложенной структурой лучше использовать контекст:
// Типыinterface TabsContext { activeTab: Accessor<string>; setTab: (id: string) => void;}
// Контекст для передачи состояния вглубь дереваconst TabsCtx = createContext<TabsContext>();
function Tabs(props: { defaultTab: string; children: JSXElement }) { const [activeTab, setActiveTab] = createSignal(props.defaultTab);
return ( <TabsCtx.Provider value={{ activeTab, setTab: setActiveTab }}> {props.children} </TabsCtx.Provider> );}
// Компонент-переключатель (без стилей)function Tab(props: { id: string; children: JSXElement }) { const ctx = useContext(TabsCtx)!; return ( <button role="tab" aria-selected={ctx.activeTab() === props.id} onClick={() => ctx.setTab(props.id)} > {props.children} </button> );}
// Компонент-панель (без стилей)function TabPanel(props: { id: string; children: JSXElement }) { const ctx = useContext(TabsCtx)!; return ( <Show when={ctx.activeTab() === props.id}> <div role="tabpanel">{props.children}</div> </Show> );}Slot-паттерн
Заголовок раздела «Slot-паттерн»Slot-паттерн — это способ принять именованные секции содержимого:
interface CardProps { header?: JSXElement; footer?: JSXElement; children?: JSXElement;}
function Card(props: CardProps) { return ( <div class="card"> <Show when={props.header}> <div class="card-header">{props.header}</div> </Show> <div class="card-body">{props.children}</div> <Show when={props.footer}> <div class="card-footer">{props.footer}</div> </Show> </div> );}
// Использование<Card header={<h2>Заголовок</h2>} footer={<button>Ок</button>}> <p>Основной контент</p></Card>@kobalte/core — headless UI для Solid
Заголовок раздела «@kobalte/core — headless UI для Solid»@kobalte/core — официальная библиотека headless-компонентов для Solid.js, вдохновлённая Radix UI:
import { Accordion } from "@kobalte/core";
// Логика: @kobalte/core// Стили: полностью твоиfunction MyAccordion() { return ( <Accordion.Root defaultValue={["item-1"]}> <Accordion.Item value="item-1"> <Accordion.Header> <Accordion.Trigger>Вопрос 1</Accordion.Trigger> </Accordion.Header> <Accordion.Content> Ответ на вопрос 1 </Accordion.Content> </Accordion.Item> </Accordion.Root> );}Доступные компоненты в Kobalte
Заголовок раздела «Доступные компоненты в Kobalte»| Компонент | Описание |
|---|---|
Accordion | Аккордеон с ARIA |
Dialog | Модальное окно |
DropdownMenu | Выпадающее меню |
Select | Кастомный select |
Tabs | Вкладки |
Toggle | Кнопка-переключатель |
Popover | Всплывающий контент |
Toast | Уведомления |
Combobox | Поиск с выбором |
Renderless Dropdown — полный пример
Заголовок раздела «Renderless Dropdown — полный пример»// Renderless компонент управляет:// - состоянием открытия// - навигацией по клавиатуре// - закрытием по клику вне// - accessibility-атрибутамиfunction DropdownLogic(props: { children: (state: { isOpen: Accessor<boolean>; toggle: () => void; close: () => void; activeIndex: Accessor<number>; }) => JSXElement;}) { const [isOpen, setIsOpen] = createSignal(false); const [activeIndex, setActiveIndex] = createSignal(-1);
// Закрытие по клику вне компонента let ref: HTMLDivElement; const handleOutsideClick = (e: MouseEvent) => { if (!ref?.contains(e.target as Node)) setIsOpen(false); }; createEffect(() => { if (isOpen()) { document.addEventListener('click', handleOutsideClick); onCleanup(() => document.removeEventListener('click', handleOutsideClick)); } });
return ( <div ref={ref!}> {props.children({ isOpen, toggle: () => setIsOpen(o => !o), close: () => setIsOpen(false), activeIndex, })} </div> );}Интерактивный пример
Заголовок раздела «Интерактивный пример»Один renderless-аккордеон, три разных визуальных темы — логика одна: