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

Headless-компоненты

Headless-компоненты дают поведение, состояние, доступность и обработку клавиатуры без стилей и разметки — UI пишешь ты, — так что получаешь полностью кастомный вид, не воюя с библиотекой; режим отказа — абстрагировать одноразовый виджет с единственным видом.

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

Тебе нужен combobox, точь-в-точь повторяющий пиксельный дизайн. Ты берёшь библиотеку компонентов и сразу начинаешь с ней воевать: перебиваешь её CSS через !important, оборачиваешь её разметку, чтобы вставить свою, латаешь её структуру DOM в угоду дизайнеру. Через два дня у тебя есть застилизованный компонент, которому ты не доверяешь и который собрал не до конца. Или ты отказываешься от библиотеки и пишешь его руками — и теперь ты владеешь навигацией стрелками, ARIA active-descendant, ловушкой фокуса, type-ahead и объявлениями для скринридеров. Обе дороги дорогие.

Headless-компонент — это третья дорога. Он поставляет тяжёлые, невидимые 80% — состояние, обработку клавиатуры, управление фокусом, подключение ARIA — и не поставляет ничего видимого. Ни стилей, ни мнений о разметке. Ты приносишь каждый <div>, каждый класс, каждый пиксель. Корректность достаётся тебе даром, а налог за внешний вид — нулевой.

Цель

После этого урока ты можешь определить headless-компонент как поведение + состояние + доступность без стилей и разметки; потребить headless-примитив, разложив (spread) его prop-геттеры и состояние по своим элементам; объяснить, почему это современный ответ на «переиспользуемое поведение, полностью кастомный вид» — ты наследуешь клавиатуру, фокус и ARIA, не воюя с CSS библиотеки; и назвать режим отказа — строить headless-абстракцию для одного внутреннего виджета с ровно одним видом — это спекулятивная общность.

1

Headless значит: библиотека владеет поведением, ты владеешь DOM. Застилизованный компонент связывает три вещи: поведение (открыть/закрыть, выбор, клавиатура), доступность (роли, aria-*, фокус) и представление (разметка + CSS). Headless-компонент оставляет первые две, а третью отдаёт тебе. Он раскрывает свою работу как хуки, render-props или prop-геттеры — но никогда как готовую разметку.

// Застилизованная библиотека: ты получаешь её <div>, её классы, её структуру. Ты переопределяешь.
<Combobox className="lib-combobox" /* now fight its internals */ />

// Headless-библиотека: она даёт поведение + props доступности; элементы пишешь ТЫ.
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({ items });
return (
  <div className="my-combobox">
    <input {...getInputProps()} className="my-input" />
    <ul {...getMenuProps()} className={isOpen ? "my-menu open" : "my-menu"}>
      {isOpen && items.map((item, i) => (
        <li key={item.id} {...getItemProps({ item, index: i })} className="my-row">
          {item.label}
        </li>
      ))}
    </ul>
  </div>
);

Библиотека решает, что должны объявлять поле ввода и каждая строка и как стрелки двигают выбор; ты решаешь, как они выглядят и где располагаются.

2

Ты наследуешь работу по доступности и клавиатуре, которую по-настоящему трудно сделать правильно. В этом и есть настоящая выгода. Корректному listbox нужны role, aria-activedescendant, aria-expanded, aria-controls, перемещающийся фокус (roving), обработка Home/End/PageUp/PageDown, type-ahead и объявления — многонедельный проект, если делать хорошо, и вечный источник багов, когда написан руками. Headless-примитив запекает это в свои prop-геттеры, так что, разложив их по своим элементам, ты получаешь виджет, соответствующий WAI-ARIA, в собственной обёртке.

// Radix headless: нестилизованные, доступные примитивы, которые ты компонуешь и стилизуешь сам.
import * as Dialog from "@radix-ui/react-dialog";

export function ConfirmDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="btn">Delete</Dialog.Trigger>
      <Dialog.Portal>
        {/* focus trap, Esc-to-close, scroll lock, aria-modal, restore-focus: all free */}
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="card">
          <Dialog.Title className="title">Delete project?</Dialog.Title>
          <Dialog.Close className="btn-danger">Confirm</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Каждый className — твой. Каждая ловушка фокуса, обработчик Esc и aria-modal — библиотеки. Ты не писал ту часть, которую большинство команд делают неправильно.

3

Кастомный вид не стоит ничего, потому что нет стилей, которые нужно переопределять. С застилизованным набором кастомизация — это вычитание: ты убираешь и переопределяешь то, что пришло в комплекте. С headless кастомизация — состояние по умолчанию: ничего не приходит застилизованным, так что тебе никогда не приходится реверс-инжинирить специфичность CSS библиотеки или её shadow-DOM. Один и тот же headless useReactTable питает и плотную админ-таблицу, и воздушную маркетинговую: поведение общее, разметка расходится полностью.

import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table";

const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });

// TanStack Table headless: даёт row model + состояние, без <table> вовсе.
return (
  <table className="my-grid">
    <tbody>
      {table.getRowModel().rows.map((row) => (
        <tr key={row.id} className="my-row">
          {row.getVisibleCells().map((cell) => (
            <td key={cell.id} className="my-cell">
              {flexRender(cell.column.columnDef.cell, cell.getContext())}
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  </table>
);

Сортировка, фильтрация, пагинация, группировка — всё поведение, нуль разметки. Ты рендеришь всё, что требует дизайн.

4

Режим отказа: headless-абстракция для одного внутреннего виджета с ровно одним видом — это спекулятивная общность. Headless оправдывает себя, когда поведение общее, а внешний вид варьируется — по приложению, по дизайн-системе или по множеству потребителей библиотеки. Если у тебя один dropdown, используемый в одном месте с одним дизайном, построить свой headless-слой useDropdown (prop-геттеры, обобщённое состояние, render-props) не покупает ничего: нет второго вида, который нужно варьировать, нет третьего потребителя, которого нужно обслужить. Ты заплатил полную цену абстракции — косвенность, обобщённый API, больше файлов — за гибкость, которой никогда не воспользуешься.

// Переинжиниринг: самодельный headless-слой для ОДНОГО компонента с ОДНИМ видом.
function useDropdown<T>() { /* prop-getters, generic state, a render-prop API… */ }
function AppMenu() {
  const { getToggleProps, getItemProps, isOpen } = useDropdown<MenuItem>();
  // …used in exactly one place, styled exactly one way, forever
}

// Senior-выбор: обычный компонент. Тянись к headless, только когда вариативность реальна.
function AppMenu() {
  const [open, setOpen] = useState(false);
  return /* the one markup this app needs */;
}

Потреблять опубликованную headless-библиотеку (Radix, Headless UI) дёшево и почти всегда правильно — кто-то уже заплатил за абстракцию, и доступность обкатана в бою. Писать свой headless-слой стоит того лишь тогда, когда у тебя действительно есть несколько видов поверх одного поведения.

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

Один и тот же виджет tabs, две сборки — смотри, как шов headless снимает работу, которой ты иначе владел бы. Дизайн требует tabs с полностью кастомной анимацией подчёркивания и нестандартными отступами.

Сборка A пишет всё руками, включая доступность, о которой никто не вспоминает до аудита:

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(0);
  return (
    <div className="tabs">
      {/* No role="tablist", no aria-selected, no arrow-key navigation,
          no aria-controls, no roving tabindex — keyboard + SR users are stranded */}
      <div className="tabrow">
        {tabs.map((t, i) => (
          <button key={t.id} className={i === active ? "tab on" : "tab"} onClick={() => setActive(i)}>
            {t.label}
          </button>
        ))}
      </div>
      <div className="panel">{tabs[active].content}</div>
    </div>
  );
}

Выглядит правильно и поставляется сломанным для пользователей клавиатуры и скринридеров. Сборка B потребляет headless-примитив (Headless UI / Radix Tabs) и поставляет только разметку и классы:

import { Tab } from "@headlessui/react";

function Tabs({ tabs }: { tabs: Tab[] }) {
  return (
    <Tab.Group> {/* arrow-key nav, roving tabindex, role+aria-selected: all included */}
      <Tab.List className="tabrow">
        {tabs.map((t) => (
          <Tab key={t.id} className={({ selected }) => (selected ? "tab on" : "tab")}>
            {t.label} {/* your underline animation lives entirely in your CSS */}
          </Tab>
        ))}
      </Tab.List>
      <Tab.Panels>
        {tabs.map((t) => <Tab.Panel key={t.id} className="panel">{t.content}</Tab.Panel>)}
      </Tab.Panels>
    </Tab.Group>
  );
}

У B тот же кастомный вид, что и у A — подчёркивание, отступы, каждый класс твой, — но навигация клавиатурой и ARIA корректны, потому что ими владеет headless-библиотека. Ты написал 20%, специфичных для дизайна, и унаследовал 80%, склонных к ошибкам. Цена (зависимость, маленький API для изучения) платится автором библиотеки один раз, за каждого потребителя. Именно тогда тянуться к headless — это senior-выбор, и ровно поэтому писать свой headless-слой для одного внутреннего вида — нет.

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

Почему headless стал доминирующим ответом на «переиспользуемое поведение, кастомный вид»? Потому что два старых ответа оба ломаются на масштабе. Полностью застилизованные наборы (старый Bootstrap, дефолты MUI) дают скорость, но запирают тебя в их внешнем виде; выбраться означает войну переопределений против их CSS, которая нарастает с каждым обновлением. Ручное написание даёт полный контроль, но переписывает тяжёлый, невидимый слой доступности в каждом проекте, плохо. Headless чисто делит разницу пополам: часть, которая одинакова везде и трудна, чтобы сделать её правильно (клавиатура, фокус, ARIA, состояние), общая и обкатана в бою; часть, которая различна везде и легка (разметка, CSS), остаётся полностью твоей. Radix, Headless UI, TanStack Table, Downshift и React Aria — все продают ровно этот шов.

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

Ловушка не в том, чтобы потреблять headless-библиотеки — это почти всегда правильно. Ловушка в том, чтобы писать headless-абстракцию преждевременно. Команда читает про prop-геттеры и render-props, загорается и оборачивает свой единственный внутренний <Sidebar> или <Modal> в обобщённый useDisclosure + render-prop API, «чтобы было переиспользуемо». Но он используется один раз, стилизован один раз, и абстракция делает код труднее читаемым ради переиспользования, которое никогда не наступит. Правило: потребляй опубликованные headless-примитивы свободно; строй свой headless-слой, только когда у тебя есть как минимум два реальных, по-разному застилизованных потребителя одного поведения. До тех пор обычный компонент — это senior-выбор.

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

В твоём приложении один dropdown настроек, используемый на одном экране, с ровно одним дизайном, который никогда не будет меняться. Коллега предлагает построить обобщённый headless-хук useDropdown (prop-геттеры, render-prop API) «для переиспользования». Каков senior-выбор?

Итог

Headless-компонент поставляет поведение, состояние, доступность и обработку клавиатуры/фокуса с нулём стилей и разметки — он раскрывает свою работу через хуки, render-props или prop-геттеры и даёт тебе принести каждый элемент и класс самому. Этот шов — современный ответ на «переиспользуемое поведение, полностью кастомный вид»: ты наследуешь тяжёлые, склонные к ошибкам 80% (перемещающийся фокус, aria-*, type-ahead, ловушки фокуса), не воюя с CSS библиотеки, и полностью владеешь лёгкими, специфичными для дизайна 20% (разметка + пиксели). Потреблять опубликованный headless-примитив — Radix, Headless UI, TanStack Table — дёшево и почти всегда senior-выбор, потому что кто-то уже заплатил за абстракцию, и доступность обкатана в бою. Писать свой headless-слой — обратное решение: делать это для одного внутреннего виджета с ровно одним видом — это спекулятивная общность, ты платишь полную цену косвенностью и обобщённым API за гибкость, которой никогда не воспользуешься. Правило, разделяющее эти два случая: headless оправдывает себя, только когда одно поведение должно носить много видов. Пока эта вариативность не реальна, тянись к обычному компоненту.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.