Перейти к содержимому

29. Renderless компоненты

Renderless-компонент (или headless-компонент) — это компонент, который управляет логикой и состоянием, но не рендерит никакой UI. Он предоставляет поведение через children или render prop, позволяя полностью контролировать визуальную часть.

Этот паттерн лежит в основе библиотек вроде Headless UI, Radix UI и @kobalte/core.

Представь, что тебе нужен аккордеон с одинаковой логикой, но разным визуальным оформлением: минималистичным для одного сайта, карточным для другого, outlined для третьего. Копировать логику в каждый компонент — антипаттерн. Renderless-подход разделяет “что делает” от “как выглядит”:

┌─────────────────────────────────┐
│ <Accordion> │ ← управляет открытыми панелями
│ поведение: открыть/закрыть │ ← предоставляет isOpen, toggle
│ accessibility: ARIA │ ← добавляет role, aria-expanded
│ keyboard nav: Enter/Space │ ← слушает keydown
│ │
│ НЕТ стилей, НЕТ HTML-структуры │
└─────────────────────────────────┘
↓ передаёт через children
┌─────────────────────────────────┐
│ Визуальный компонент │ ← ты пишешь сам
│ (<MinimalAccordion> / │ ← любые стили
│ <CardAccordion> / │ ← любая HTML-структура
│ <OutlinedAccordion>) │
└─────────────────────────────────┘

В 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) })}</>
);
}
// Потребитель — полный контроль над UI
function MyPage() {
return (
<Accordion>
{({ isOpen, toggle }) => (
<div>
<button onClick={toggle}>{isOpen ? '▲' : '▼'} Раздел</button>
<Show when={isOpen}>
<p>Содержимое раздела</p>
</Show>
</div>
)}
</Accordion>
);
}

Для более сложных компонентов с вложенной структурой лучше использовать контекст:

// Типы
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-паттерн — это способ принять именованные секции содержимого:

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-компонентов для 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>
);
}
КомпонентОписание
AccordionАккордеон с ARIA
DialogМодальное окно
DropdownMenuВыпадающее меню
SelectКастомный select
TabsВкладки
ToggleКнопка-переключатель
PopoverВсплывающий контент
ToastУведомления
ComboboxПоиск с выбором
// 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-аккордеон, три разных визуальных темы — логика одна: