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

Хуки как headless-паттерн

Кастомный хук — самый эргономичный headless-паттерн: useX() возвращает состояние, обработчики и prop-getter-ы, которые вызывающий разворачивает на свою разметку, — переиспользование логики без обёртки и вложенности. Режим отказа — хук, возвращающий JSX.

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

Render-props решили переиспользование логики, передавая функцию как children, но ценой были компонент-обёртка и пирамида вложенности — три поведения подряд означают три render-props-обёртки, обёрнутые друг в друга, а твою настоящую разметку — погребённой на самом дне. Поведение было переиспользуемым; эргономика — нет.

Кастомный хук взял ту же идею — отделить поведение от разметки — и убрал обёртку целиком. useDisclosure() возвращает состояние открыто/закрыто, обработчики и пропсы доступности, нужные твоей кнопке и панели; ты разворачиваешь их на свой собственный JSX. Та же headless-логика, ноль вложенности, полная свобода разметки. Вот почему хуки — современный преемник render-props для переиспользования логики, — и знать черту, на которой «headless-хук» окупается, есть навык уровня senior.

Цель

После этого урока ты можешь собрать headless кастомный хук, возвращающий состояние, обработчики и prop-getter-ы (getButtonProps, getPanelProps), чтобы вызывающий развернул их на свои собственные элементы; объяснить, почему хуки-как-headless обходят render-props в переиспользовании логики (без обёртки, без вложенности, без ограничений на разметку); распознать два режима отказа — хук, возвращающий JSX (это компонент, а не headless-хук), и вынос headless-хука с единственным вызывающим, где инлайн-код проще, — и решить, какой из них перед тобой в реальной ситуации.

1

Headless-хук возвращает поведение, никогда разметку — useX() даёт тебе состояние плюс обработчики и пропсы для подключения, а рендеришь ты что хочешь. «Headless» значит, что у логики нет мнения о DOM. Хук владеет что (открыто/закрыто, toggle), а вызывающий владеет как (<button>, <details>, кастомная карточка). Начни с минимальной формы: состояние и действия, которые его меняют.

import { useCallback, useState } from "react";

function useDisclosure(initial = false) {
  const [isOpen, setOpen] = useState(initial);
  const open = useCallback(() => setOpen(true), []);
  const close = useCallback(() => setOpen(false), []);
  const toggle = useCallback(() => setOpen((v) => !v), []);
  return { isOpen, open, close, toggle };
}

Этого уже хватает на диалог, выпадающее меню, ряд аккордеона. Ничто внутри не знает и не заботится, к какому элементу ты прицепишь toggle, — и в этом весь смысл headless.

2

Prop-getter-ы — это апгрейд, делающий headless-хук эргономичным: они упаковывают подключение — onClick, aria-expanded, id, — так что вызывающий разворачивает один объект вместо того, чтобы помнить пять атрибутов. Getter — это функция, возвращающая пропсы, нужные элементу, и, что важно, она композирует собственные обработчики вызывающего, а не перезаписывает их.

function useDisclosure(initial = false) {
  const [isOpen, setOpen] = useState(initial);
  const toggle = useCallback(() => setOpen((v) => !v), []);
  const panelId = useId();

  const getButtonProps = useCallback(
    (props: React.ComponentProps<"button"> = {}) => ({
      ...props,
      "aria-expanded": isOpen,
      "aria-controls": panelId,
      onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
        props.onClick?.(e); // обработчик вызывающего срабатывает первым
        toggle();
      },
    }),
    [isOpen, panelId, toggle],
  );

  const getPanelProps = useCallback(
    (props: React.ComponentProps<"div"> = {}) => ({
      ...props,
      id: panelId,
      hidden: !isOpen,
    }),
    [isOpen, panelId],
  );

  return { isOpen, toggle, getButtonProps, getPanelProps };
}

Вызов props.onClick?.(e) перед toggle() — та деталь, что отделяет настоящий prop-getter от игрушечного: вызывающий может передать свой собственный onClick, и он всё равно сработает. Так headless-хук остаётся композируемым, а не захватывает элемент.

3

Вызывающий разворачивает getter-ы на свою собственную разметку — и разметка полностью свободна. В этом выигрыш над render-props: между хуком и твоим JSX не сидит компонент-обёртка, поэтому нет вложенности и нет раскладки, которую хук тебе навязывает. Тот же хук движет кнопку-с-панелью, <details> или полностью кастомный дизайн — меняются лишь цели разворота.

function FaqItem({ q, a }: { q: string; a: string }) {
  const { getButtonProps, getPanelProps } = useDisclosure();
  return (
    <section>
      <button {...getButtonProps({ className: "faq-trigger" })}>{q}</button>
      <div {...getPanelProps({ className: "faq-panel" })}>{a}</div>
    </section>
  );
}

Заметь getButtonProps({ className: "faq-trigger" })className вызывающего проходит насквозь, потому что getter разворачивает ...props первым. Подключение доступности (aria-expanded, aria-controls, id, hidden) берёт на себя хук; стилизация и структура — за вызывающим. Поведение и разметка, чисто разделённые.

4

Режим отказа номер один: хук, возвращающий JSX, — не headless-хук, это компонент, надевший префикс use, и он выбрасывает каждое преимущество. В тот миг, когда твой хук возвращает <div>…</div>, вызывающий больше не может выбрать элемент, стилизацию или структуру; ты вернулся к обёртке без всякой ясности компонента. Хочешь вернуть UI — напиши компонент. Хук возвращает данные и поведение — значения, обработчики, getter-ы, — никогда элементы.

// НЕВЕРНО: это компонент, притворяющийся хуком
function useDisclosure() {
  const [isOpen, setOpen] = useState(false);
  return <button onClick={() => setOpen((v) => !v)}>{isOpen ? "Hide" : "Show"}</button>;
  // вызывающий не может перестилизовать, выбрать элемент, композировать — headless-свобода потеряна
}

// ВЕРНО: возвращай поведение; рендерит вызывающий
function useDisclosure() {
  const [isOpen, setOpen] = useState(false);
  return { isOpen, toggle: () => setOpen((v) => !v) };
}

Если ловишь себя на том, что тянешься за JSX внутри хука, — это сигнал, что тебе всё это время был нужен компонент.

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

От render-props-обёртки к headless-хуку — то же поведение, вложенность исчезла. <Toggle> отдаёт состояние вкл/выкл через render-prop. Два поведения подряд уже показывают пирамиду:

// до: render-props — обёртка на каждое поведение, разметка погребена на дне
function Toggle({ children }: { children: (s: { on: boolean; toggle: () => void }) => React.ReactNode }) {
  const [on, setOn] = useState(false);
  return <>{children({ on, toggle: () => setOn((v) => !v) })}</>;
}

<Toggle>
  {(menu) => (
    <Toggle>
      {(tooltip) => (
        <button onClick={menu.toggle} aria-expanded={menu.on}>
          Menu {tooltip.on ? "(?)" : ""}
        </button>
      )}
    </Toggle>
  )}
</Toggle>

Две обёртки <Toggle>, и настоящая кнопка — на два замыкания вглубь. Переключись на headless-хук, и обе обёртки исчезают:

// после: headless-хук — плоско, без обёрток, prop-getter делает ARIA-подключение
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  const getToggleProps = useCallback(
    (p: React.ComponentProps<"button"> = {}) => ({
      ...p,
      "aria-pressed": on,
      onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
        p.onClick?.(e);
        toggle();
      },
    }),
    [on, toggle],
  );
  return { on, toggle, getToggleProps };
}

function MenuButton() {
  const menu = useToggle();
  const tooltip = useToggle();
  return (
    <button {...menu.getToggleProps({ className: "menu" })}>
      Menu {tooltip.on ? "(?)" : ""}
    </button>
  );
}

Два поведения теперь — два плоских вызова useToggle(), разметка — одна читаемая строка, а aria-pressed подключён getter-ом. Это и есть миграция render-props-в-хуки на одном экране: идентичное разделение поведения и разметки, без всякого налога на вложенность.

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

Почему prop-getter-ы возвращают функцию, а не просто объект пропсов? Потому что getter обязан слить пропсы вызывающего с пропсами хука. Будь getButtonProps фиксированным объектом, вызывающему, добавляющему свой onClick или className, пришлось бы сливать их вручную, и он неизбежно затёр бы onClick хука (потеряв toggle) или наоборот. Форма функции позволяет хуку контролировать слияние — вызвать обработчик вызывающего, затем запустить свой, и пробросить остальные пропсы вызывающего насквозь. Ровно так формируют свои API зрелые headless-библиотеки (Downshift, React Aria, примитивы Radix), и потому «prop-getter» стало стандартным именем паттерна.

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

Противоположный отказ к возврату JSX — переизвлечение: вытаскивание «headless-хука», когда вызывающий ровно один, а логика — три строки. useDisclosure, разделяемый диалогом, выпадающим меню и аккордеоном, оправдывает свой файл. useDisclosure, использованный единожды, в одном компоненте, без второго потребителя на горизонте, — косвенность ради косвенности; теперь читателю надо прыгать в другой файл, чтобы узнать, что переключается булев флаг. Машинерия prop-getter-ов (слияние обработчиков, зависимости useCallback, id для aria-controls) — реальная цена; плати её, когда поведение действительно переиспользуется или действительно замысловато, а не рефлекторно. Инлайновый useState(false) — выбор уровня senior куда чаще, чем намекает каталог headless-хуков.

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

Коллега пишет useDropdown(), который внутри управляет состоянием открытия и возвращает полностью стилизованный элемент-меню <ul>, готовый вставить. Если судить как headless-хук, в чём ключевая проблема?

Итог

Кастомный хук — самый эргономичный headless-паттерн: useX() возвращает состояние, обработчики и prop-getter-ы — функции вроде getButtonProps/getPanelProps, которые упаковывают подключение (onClick, aria-expanded, id) и композируют собственные пропсы вызывающего, — а компонент разворачивает их на какую угодно разметку. Вот почему хуки-как-headless — преемник render-props для переиспользования логики: то же разделение поведения и разметки, но без компонента-обёртки, без пирамиды вложенности и без ограничений на твой JSX. Делай getter-ы возвращающими функции, чтобы хук контролировал слияние пропсов вызывающего и хука. Затем следи за обоими режимами отказа: хук, возвращающий JSX, — компонент под маской, выбрасывающий каждое преимущество (возвращай значения, не элементы); а «headless-хук» с единственным вызывающим — переизвлечение, инлайновый useState читается лучше. Подбирай паттерн под давление: переиспользование или замысловатое поведение оправдывают хук; одноразовый булев флаг — нет.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.