Опубліковано
21 хв читання

Хто володіє розміткою? HOC, render props, хуки та headless-компоненти в React

Автори
  • Learna
    Ім’я
    Learna

У таблиці замовлень є пагінація: кнопки з номерами, «Назад» і «Далі», трикрапка між далекими сторінками. Потім з’являється мобільний дизайн із компактним «‹ Сторінка 3 з 12 ›», а адмінці потрібен ще один вигляд. Правила лишаються тими самими: яка сторінка поточна, які номери видно, що робити на першій та останній сторінках і як повідомити користувачу скринрідера, де він перебуває. Змінюється лише розмітка.

Два очевидні рішення погано витримують розвиток продукту. Якщо копіювати логіку, копії почнуть розходитися: одна не обмежить номер сторінки після фільтрації, інша забуде позначити поточну сторінку для допоміжних технологій. Якщо ж розширювати один компонент через variant, showEllipsis, hideArrowsAtEdges тощо, кожен новий дизайн додаватиме ще одну властивість і гілку.

HOC, render props, власні хуки та headless-компоненти — чотири відповіді на запитання: якщо поведінка спільна, хто володіє розміткою і як отримує цю поведінку? Побудуємо ту саму пагінацію всіма чотирма способами, розглянемо їхні слабкі місця й сформуємо орієнтири для код-рев’ю.

За даними оригінальної статті, код перевірено в жовтні 2026 року з React і React DOM 19.3.1, Vitest 5.0.5, jsdom 30.1.3 та React Testing Library 16.3.3. Перевірок у справжніх браузерах або зі скринрідерами не було.

Три завдання компонента

Перш ніж порівнювати патерни, розділимо завдання інтерактивного компонента:

  • Стан: що істинне зараз — наприклад, поточна сторінка.
  • Поведінка: правила й реакції — утримувати сторінку в межах, обирати видимі номери, визначати дію «Далі» на останній сторінці. Сюди належить і семантика доступності: назва навігаційної області та позначка поточної сторінки.
  • Представлення: елементи, компонування й стилі.

Звичайний компонент поєднує всі три завдання. Кожен патерн нижче залишає стан і поведінку в одному спільному місці, а екрану віддає власне представлення. Відрізняються напрямок викликів, місце доступу до спільних значень і можливості конфліктів.

Кент Доддс описує цей підхід як інверсію керування: нехай абстракція робить менше, а решту визначає її користувач. Різні патерни передають різний обсяг контролю.

Нульовий крок — частина, якій не потрібен React

Найпростіша для повторного використання частина пагінації — чиста функція, яка вирішує, які номери показати. Без стану, хуків і JSX:

// page-items.js — plain JavaScript, no React
// getPageItems({ page: 6, pageCount: 12 }) → [1, 'start-gap', 5, 6, 7, 'end-gap', 12]
export function getPageItems({ page, pageCount, siblings = 1 }) {
  if (pageCount < 1) return [];

  const start = Math.max(2, page - siblings);
  const end = Math.min(pageCount - 1, page + siblings);
  const items = [1];

  // A gap should hide at least two pages; otherwise just show the page.
  if (start > 3) {
    items.push('start-gap');
  } else {
    for (let p = 2; p < start; p++) items.push(p);
  }

  for (let p = start; p <= end; p++) items.push(p);

  if (end < pageCount - 2) {
    items.push('end-gap');
  } else {
    for (let p = end + 1; p < pageCount; p++) items.push(p);
  }

  if (pageCount > 1) items.push(pageCount);
  return items;
}

siblings визначає кількість сторінок з обох боків від поточної. Трикрапка доречна, лише коли приховує щонайменше дві сторінки: заміна одного номера не економить місця, але ховає інформацію. Маркери пропусків — рядки, тому їх можна використати як React-ключі.

Почати з цього — не відступ від теми. Звичайну функцію легко тестувати без рендерингу й act; вона працює на сервері, у будь-якому компоненті та навіть в іншому фреймворку. Подальші патерни розв’язують наступне завдання: пов’язати стан поточної сторінки з чужою розміткою.

Патерн 1 — компоненти вищого порядку

Компонент вищого порядку — функція, яка приймає компонент і повертає новий (документація React). У 2016 році команда React рекомендувала HOC замість mixins для наскрізної логіки на кшталт підписки на джерело даних (Mixins Considered Harmful). Роками це був один з основних способів додати повторно використовувану поведінку.

// withPagination.jsx — a higher-order component
import { useState } from 'react';

export function withPagination(Component, { pageSize = 10 } = {}) {
  function WithPagination({ items, ...rest }) {
    const [page, setPage] = useState(1);
    const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
    const current = Math.min(page, pageCount);
    const start = (current - 1) * pageSize;

    return (
      <Component
        {...rest}
        pageItems={items.slice(start, start + pageSize)}
        page={current}
        pageCount={pageCount}
        onPageChange={setPage}
      />
    );
  }

  const name = Component.displayName || Component.name || 'Component';
  WithPagination.displayName = `withPagination(${name})`;
  return WithPagination;
}
// OrderList.jsx — it can't tell where these props come from
import { withPagination } from './withPagination.jsx';

export function OrderList({ ref, pageItems, page, pageCount, onPageChange }) {
  return (
    <section ref={ref}>
      <ul>
        {pageItems.map((order) => (
          <li key={order.id}>{order.title}</li>
        ))}
      </ul>
      <button type="button" disabled={page === 1} onClick={() => onPageChange(page - 1)}>
        Previous
      </button>
      <span>
        Page {page} of {pageCount}
      </span>
      <button type="button" disabled={page === pageCount} onClick={() => onPageChange(page + 1)}>
        Next
      </button>
    </section>
  );
}

export const PaginatedOrderList = withPagination(OrderList, { pageSize: 20 });
// Usage: <PaginatedOrderList items={orders} />

PaginatedOrderList рендерить WithPagination, який зберігає стан сторінки, обирає потрібні елементи й передає OrderList чотири додаткові властивості. OrderList лишається звичайним компонентом, який легко перевірити через props. У цьому сила HOC: загорнутий компонент не потрібно змінювати.

Витрати стають помітними в реальному застосунку.

Контракт прихований. Читаючи OrderList, ви не бачите, звідки взялися page та onPageChange. Потрібно знайти місце обгортання — часто в іншому файлі — і прочитати HOC.

Властивості конфліктують без попереджень. Уявімо, що CMS уже має prop page для документа, який редагується:

// RevisionList.jsx — this screen already uses a prop called `page`
import { withPagination } from './withPagination.jsx';

function RevisionList({ page, pageItems }) {
  return (
    <section>
      <h2>Revisions of “{page.title}”</h2>
      <ul>
        {pageItems.map((revision) => (
          <li key={revision.id}>{revision.summary}</li>
        ))}
      </ul>
    </section>
  );
}

export const PaginatedRevisions = withPagination(RevisionList);
// <PaginatedRevisions page={cmsPage} items={revisions} /> renders
// “Revisions of “”” — the HOC replaced `page` with a number, and nothing warned.

HOC спочатку розгортає ...rest, а потім задає page: номер сторінки перезаписує документ, і заголовок втрачає назву. Якщо поміняти порядок, документ збережеться, але компонент не отримає номер сторінки. Попередження не буде; кожна наступна обгортка додає ще один шанс перезаписати чужий prop.

Конфігурація задається під час визначення. pageSize: 20 обирається під час завантаження модуля. Якщо екрану потрібен інший розмір, HOC має прочитати додатковий prop і прибрати його перед передаванням далі. Це ще одна частина прихованого контракту.

Дерево стає глибшим. Кожен HOC додає шар компонентів. Представляючи хуки, команда React згадувала «wrapper hell» з HOC, render props, providers і consumers у DevTools (Introducing Hooks). displayName на кшталт withPagination(OrderList) принаймні робить дерево читабельним — тому він є в прикладі.

Деталі, про які легко забути. Статичні методи загорнутого компонента автоматично не копіюються; стара документація радить hoist-non-react-statics. І ніколи не створюйте HOC під час рендерингу:

function OrdersScreen({ orders }) {
  const [dense, setDense] = useState(false);
  // 🔴 A NEW component type on every render: the page resets whenever `dense` changes
  const List = withPagination(OrderList, { pageSize: 20 });
  // ...
}

Кожен рендеринг створює новий тип компонента, тому React демонтує старий список разом зі станом і монтує новий (збереження та скидання стану). У тестах статті навіть перемикання стороннього прапорця «Dense rows» повертало список на першу сторінку. Правило react-hooks/static-components виявляє створення компонента під час рендерингу. Обгортайте його один раз на рівні модуля.

Що змінилося з ref у React 19. Старі посібники вимагали forwardRef, бо ref не проходив через HOC. Починаючи з React 19, функціональний компонент отримує ref як звичайний prop. Документація forwardRef зазначає, що він більше не потрібен для цього сценарію й буде deprecated у майбутньому. Тому OrderList просто читає ref: він проходить крізь ...rest функціонального WithPagination до section. За даними статті, це перевірено на React 19.3.1. Для класового HOC ref вказує на екземпляр класу; передавання також зламається, якщо HOC відкине невідомі props.

Коли HOC усе ще доречний: коли потрібно додати поведінку компоненту, який ви не можете або не хочете редагувати; підтримати класовий компонент, який не може викликати хуки; або використати наявне API. FAQ React Router v6 показує тонкий withRouter поверх хуків для класів. React Redux досі підтримує connect, хоча радить хуки за замовчуванням. Для нового коду врахуйте: навіть стара сторінка HOC попереджає, що цей патерн уже не є типовим у сучасному React.

Патерн 2 — render props: компонент викликає вашу функцію

Render prop — властивість, значенням якої є функція. Компонент викликає її, щоб отримати розмітку (документація React). Замість обгортання вашого компонента спільний компонент викликає вашу функцію:

// Paginate.jsx — a render-prop component
import { useState } from 'react';

export function Paginate({ items, pageSize = 10, children }) {
  const [page, setPage] = useState(1);
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
  const current = Math.min(page, pageCount);
  const start = (current - 1) * pageSize;

  return children({
    pageItems: items.slice(start, start + pageSize),
    page: current,
    pageCount,
    setPage,
  });
}
// RevisionsPanel.jsx — you name the values and you own the markup
import { Paginate } from './Paginate.jsx';

export function RevisionsPanel({ cmsPage, revisions }) {
  return (
    <Paginate items={revisions} pageSize={5}>
      {({ pageItems, page, pageCount, setPage }) => (
        <section>
          <h2>Revisions of “{cmsPage.title}”</h2>
          <ul>
            {pageItems.map((revision) => (
              <li key={revision.id}>{revision.summary}</li>
            ))}
          </ul>
          <button type="button" disabled={page === 1} onClick={() => setPage(page - 1)}>
            Previous
          </button>
          <button type="button" disabled={page === pageCount} onClick={() => setPage(page + 1)}>
            Next
          </button>
        </section>
      )}
    </Paginate>
  );
}

Порівняно з HOC покращилися три речі:

  • Потік даних видно. Усі значення колбека записані в його параметрах.
  • Немає конфліктів назв. Ви самі деструктуруєте й називаєте дані: cmsPage і page можуть співіснувати.
  • Конфігурація задається під час рендерингу. pageSize={5} — звичайний prop; інший екран може передати інше значення.

А три речі стали складнішими:

  • Значення доступні лише в колбеку. RevisionsPanel не може прочитати page у власному тілі для синхронізації URL, попереднього завантаження або data hook. Викликати хук у вкладеному колбеку забороняють правила хуків. Доводиться піднімати стан або додавати ще один компонент.
  • Вкладеність. Три render-prop-компоненти створюють три рівні колбеків.
  • Нюанс мемоізації. Inline-функція має нове посилання на кожному рендерингу. Це заважає поверхневому порівнянню props у PureComponent та memo. Проблема виникає лише для мемоізованого отримувача; спочатку вимірюйте.

Проте render props не застаріли. Вони доречні, коли компонент вирішує коли, де або скільки разів рендерити ваш UI. Хук цього не робить: він повертає дані. FlatList викликає renderItem для рядків, Controller React Hook Form передає field у render, а React пропонує render props замість крихких маніпуляцій через Children і cloneElement. Кент Доддс писав про це ще у 2018 році.

Патерн 3 — власні хуки: компонент викликає поведінку

Хуки з’явилися в React 16.8 у лютому 2019 року. Команда пояснювала: React бракувало зручного примітива для спільної логіки зі станом, а HOC і render props вимагали перебудовувати компоненти (Introducing Hooks). Власний хук знову змінює напрямок: ваш компонент викликає спільний код і отримує звичайні значення.

// usePagination.js — first version: state and derived values only
import { useState } from 'react';
import { getPageItems } from './page-items.js';

export function usePagination({ itemCount, pageSize = 10 }) {
  const [page, setPage] = useState(1);
  const pageCount = Math.max(1, Math.ceil(itemCount / pageSize));
  const current = Math.min(page, pageCount);

  return {
    page: current,
    pageCount,
    start: (current - 1) * pageSize,
    end: Math.min(current * pageSize, itemCount),
    items: getPageItems({ page: current, pageCount }),
    goTo: (target) => setPage(Math.min(Math.max(target, 1), pageCount)),
  };
}
// OrdersPage.jsx — using the first version of the hook
import { usePagination } from './usePagination.js';

export function OrdersPage({ orders }) {
  const { page, start, end, items, goTo } = usePagination({
    itemCount: orders.length,
    pageSize: 20,
  });

  return (
    <>
      <ul>
        {orders.slice(start, end).map((order) => (
          <li key={order.id}>{order.title}</li>
        ))}
      </ul>
      <nav aria-label="Pagination">
        {items.map((item) =>
          typeof item === 'number' ? (
            <button
              key={item}
              type="button"
              aria-current={item === page ? 'page' : undefined}
              onClick={() => goTo(item)}
            >
              {item}
            </button>
          ) : (
            <span key={item}>…</span>
          ),
        )}
      </nav>
    </>
  );
}

Тепер значення доступні в тілі компонента. page можна передати data hook, записати в URL або показати в заголовку. Без обгорток і вкладених колбеків; хуки поєднуються як звичайні функції. Vue дійшов подібного висновку: composables призначені для повторного використання логіки, а компоненти — коли разом повторюються логіка та розмітка.

Два факти часто спричиняють плутанину:

  1. Хуки діляться логікою, а не станом. Кожен виклик має незалежний стан (документація React). Якщо header і footer окремо викличуть usePagination({ itemCount: 95 }), зміна одного не змінить інший. Для спільної сторінки викличте хук один раз у спільному предку й передайте результат або зберігайте стан у URL чи context.
  2. Правила хуків залишаються чинними. Викликайте їх на верхньому рівні компонента або іншого хука, не у вкладених функціях, умовах чи циклах (правила).

Але користувач першої версії ще має пам’ятати type="button", aria-current, назву nav і розмітку пропусків. Кожна нова пагінація мусить повторювати ці рішення. Рано чи пізно щось забудуть: хук повторно використовує стан, але не семантику.

Патерн 4 — headless-хуки: поведінка й семантика без розмітки

У статті Джунтао Цю headless-компонент відповідає лише за логіку й стан, не нав’язуючи UI; часто його реалізують як хук. TanStack Table описує headless-бібліотеки так само: логіка, стан, обробка даних та API, але без розмітки й стилів.

Від першого хука headless-версію відрізняє те, що вона також визначає спільні атрибути й обробники. Вона повертає prop getters — функції, що створюють props для розгортання на елементі. За словами автора, першою цей підхід використала Downshift. Її README пояснює: getters дають необхідні ARIA-атрибути, не обмежуючи розмітку. Чому функції, а не об’єкти? Функція може об’єднати ваші props зі своїми — наприклад, виконати обидва onClick, а не перезаписати один.

// usePagination.js — headless: state, behavior, and semantics; no markup
import { useState } from 'react';
import { getPageItems } from './page-items.js';

const clamp = (n, min, max) => Math.min(Math.max(n, min), max);

// Run the consumer's handler first; if it calls event.preventDefault(), skip ours.
function composeHandlers(theirs, ours) {
  return (event) => {
    theirs?.(event);
    if (!event.defaultPrevented) ours(event);
  };
}

export function usePagination({
  itemCount,
  pageSize = 10,
  siblings = 1,
  page: controlledPage, // pass `page` + `onPageChange` to control the hook
  defaultPage = 1,
  onPageChange,
}) {
  const [ownPage, setOwnPage] = useState(defaultPage);
  const isControlled = controlledPage !== undefined;
  const pageCount = Math.max(1, Math.ceil(itemCount / pageSize));
  const page = clamp(isControlled ? controlledPage : ownPage, 1, pageCount);

  function goTo(target) {
    const next = clamp(target, 1, pageCount);
    if (next === page) return;
    if (!isControlled) setOwnPage(next);
    onPageChange?.(next);
  }

  return {
    page,
    pageCount,
    start: (page - 1) * pageSize,
    end: Math.min(page * pageSize, itemCount),
    items: getPageItems({ page, pageCount, siblings }),
    hasPrevious: page > 1,
    hasNext: page < pageCount,
    goTo,

    getNavProps: (props = {}) => ({ 'aria-label': 'Pagination', ...props }),

    getPageProps: (target, { onClick, ...props } = {}) => ({
      type: 'button',
      'aria-label': `Page ${target}`,
      'aria-current': target === page ? 'page' : undefined,
      ...props,
      onClick: composeHandlers(onClick, () => goTo(target)),
    }),

    getPreviousProps: ({ onClick, ...props } = {}) => ({
      type: 'button',
      'aria-label': 'Previous page',
      disabled: page === 1,
      ...props,
      onClick: composeHandlers(onClick, () => goTo(page - 1)),
    }),

    getNextProps: ({ onClick, ...props } = {}) => ({
      type: 'button',
      'aria-label': 'Next page',
      disabled: page === pageCount,
      ...props,
      onClick: composeHandlers(onClick, () => goTo(page + 1)),
    }),
  };
}

Ось рішення, які хук приймає один раз для всіх інтерфейсів:

  • Спочатку defaults, потім ваші props, наприкінці — композиція обробників. getNavProps({ 'aria-label': 'Orders pages' }) замінює стандартну назву, але ваш onClick не стирає логіку хука: composeHandlers виконує обидва.
  • Можливість перехопити поведінку. Після вашого event.preventDefault() хук пропускає власний обробник — наприклад, якщо перед зміною сторінки потрібно підтвердити незбережені правки. Таку конвенцію використовує й React Router.
  • Задокументована семантика. Іменована навігаційна область, назви «Page N» і aria-current="page" відповідають пагінації GOV.UK. У наборі лише один елемент має бути поточним (MDN). Якщо на сторінці дві пагінації, їхні навігаційні області потребують різних назв (WAI-ARIA APG).
  • type: 'button'. Інакше кнопка всередині форми може відправити її (MDN). Один default у хуку виправляє всі його UI.
  • Стан поруч із getters. hasPrevious і hasNext дозволяють обирати між прихованою та disabled-кнопкою. GOV.UK приховує недоступні переходи, а APG допускає збереження видимих недоступних контролів. Хук не має нав’язувати одну конвенцію всім продуктам.
  • Керований або внутрішній стан. Передайте page й onPageChange, якщо станом володіє батько, наприклад через URL або серверний запит. Downshift називає це control props. Без них хук керує станом сам.
  • Getters — не хуки. Це звичайні функції, тому getPageProps(item) можна викликати в map.

Два інтерфейси, один хук:

// NumberedPager.jsx — a full pager for wide screens
export function NumberedPager({ pagination }) {
  const { items, hasPrevious, hasNext, getNavProps, getPageProps, getPreviousProps, getNextProps } =
    pagination;

  return (
    <nav {...getNavProps({ className: 'pager' })}>
      {hasPrevious && <button {...getPreviousProps()}>Previous</button>}
      <ol>
        {items.map((item) =>
          typeof item === 'number' ? (
            <li key={item}>
              <button {...getPageProps(item)}>{item}</button>
            </li>
          ) : (
            <li key={item}>…</li>
          ),
        )}
      </ol>
      {hasNext && <button {...getNextProps()}>Next</button>}
    </nav>
  );
}
// CompactPager.jsx — same hook, a different UI for narrow screens
export function CompactPager({ pagination }) {
  const { page, pageCount, goTo, getNavProps, getPreviousProps, getNextProps } = pagination;

  return (
    <nav {...getNavProps({ className: 'pager pager--compact' })}>
      <button {...getPreviousProps()}>‹</button>
      <label>
        Page{' '}
        <select value={page} onChange={(event) => goTo(Number(event.target.value))}>
          {Array.from({ length: pageCount }, (_, i) => (
            <option key={i + 1} value={i + 1}>
              {i + 1}
            </option>
          ))}
        </select>{' '}
        of {pageCount}
      </label>
      <button {...getNextProps()}>›</button>
    </nav>
  );
}
// OrdersPage.jsx — one hook, two interchangeable UIs
import { usePagination } from './usePagination.js';
import { NumberedPager } from './NumberedPager.jsx';
import { CompactPager } from './CompactPager.jsx';

export function OrdersPage({ orders, compact = false }) {
  const pagination = usePagination({ itemCount: orders.length, pageSize: 20 });
  const Pager = compact ? CompactPager : NumberedPager; // choosing an existing component is fine

  return (
    <>
      <ul>
        {orders.slice(pagination.start, pagination.end).map((order) => (
          <li key={order.id}>{order.title}</li>
        ))}
      </ul>
      <Pager pagination={pagination} />
    </>
  );
}

Стан живе в OrdersPage, тому перемикання дизайну не скидає сторінку. const Pager = compact ? CompactPager : NumberedPager допустимий: він обирає між двома компонентами, визначеними на рівні модуля, а не створює новий тип під час рендерингу.

Два зауваження щодо використання. По-перше, передавайте свої обробники всередину getters:

// ✅ Both your handler and the hook's run
<button {...getNextProps({ onClick: () => track('pager_next') })}>Next</button>

// 🔴 A prop written after the spread replaces the hook's onClick: the page never changes
<button {...getNextProps()} onClick={() => track('pager_next')}>Next</button>

По-друге, ось керований режим на практиці:

// OrdersFromServer.jsx — controlled: the parent owns the page
import { useState } from 'react';
import { usePagination } from './usePagination.js';
import { NumberedPager } from './NumberedPager.jsx';

export function OrdersFromServer({ totalCount }) {
  const [page, setPage] = useState(1); // could come from the URL instead
  const pagination = usePagination({
    itemCount: totalCount,
    pageSize: 20,
    page,
    onPageChange: setPage,
  });

  // Fetch the rows for `page` with your data library and render them here.
  return <NumberedPager pagination={pagination} />;
}

Тестуйте headless-хук там, де користувач із ним взаємодіє: рендерте кожен UI і шукайте елементи за роллю та доступною назвою, наприклад getByRole('button', { name: 'Page 3' }). Якщо хтось забуде розгорнути getter, запит не знайде елемент — саме таку регресію й потрібно ловити. Для чистої getPageItems достатньо дешевих табличних unit-тестів.

Порівняння

HOCRender propВласний хукHeadless-хук
Хто кого викликаєОбгортка рендерить ваш компонентКомпонент викликає вашу функціюВаш компонент викликає хукВаш компонент викликає хук і розгортає getters
Де доступні значенняУ props загорнутого компонентаЛише в колбекуУ тілі компонентаУ тілі компонента
Хто володіє розміткоюВи, але обгортка визначає propsВи, усередині колбекаВиВи; хук дає атрибути й обробники
Типовий збійКонфлікти props; втрата стану при створенні під час рендерингуЗначення замкнені в колбеку; вкладеністьОчікування спільного стану від двох викликівЗабутий getter непомітно порушує семантику
Де доречнийКласи, незмінювані компоненти, наявні APIКероване компонентом рендерення рядків і слотівБільшість нової спільної логікиОдна поведінка, багато дизайнів

Як поширювати headless-поведінку — хуки чи неоформлені компоненти

Виробничі бібліотеки пропонують headless-поведінку у двох формах.

Хуки з prop getters: Downshift (useSelect, useCombobox, useMultipleSelection), хуки React Aria та TanStack Table. Ви створюєте кожен елемент; бібліотека дає стан, атрибути й обробники.

Компоненти без стилів: Radix Primitives, Headless UI, React Aria Components та Base UI. Частини віджета — trigger, list, item — є окремими компонентами зі спільним context (патерн compound components). Вони створюють структуру, а візуальні рішення залишають вам. Radix бере на себе ролі, ARIA, фокус і клавіатуру, орієнтуючись на WAI-ARIA. Headless UI пропонує доступні неоформлені компоненти для Tailwind CSS, а Base UI — базові компоненти для доступних інтерфейсів.

Компроміс той самий: компоненти вимагають менше ручного підключення, і їхню структуру складніше зламати забутим getter. Хуки дають повний контроль над елементами й вкладеністю, але й більше відповідальності. React Aria пропонує обидва рівні. Команди часто будують шари: headless-бібліотека, оформлені компоненти дизайн-системи, продуктові екрани.

Дві практичні поради:

  • Для складних віджетів беріть бібліотеку. Combobox, menu, dialog і date picker потребують тонкої роботи з клавіатурою, фокусом та скринрідером. Навіть навчальна стаття Цю, яка будує dropdown вручну, радить зрілі бібліотеки для production. Власні headless-хуки пишіть для правил вашої області: бронювання, пагінації, toolbar з урахуванням прав.
  • Server Components і dot notation. Частини compound components часто доступні як Tabs.List. Серверний компонент не може так звертатися до властивостей клієнтського модуля: це описують React error 591 і документація Mantine. Обирайте бібліотеки й експортуйте свої компоненти також через окремі named exports.

Для довідки, оригінальна стаття фіксує версії npm станом на 24 жовтня 2026 року: downshift 9.4.0, react-aria 3.53.1, react-aria-components 1.22.1, radix-ui 1.8.1, @headlessui/react 2.2.10, @base-ui/react 1.9.0, @tanstack/react-table 9.3.1. Це історичний знімок, а не рекомендація встановлювати саме ці версії.

Як обрати патерн

Якщо потрібно…Оберіть…Чому…
Повторно використати логіку зі станом у новому кодіВласний хукЗначення лишаються в компоненті, без обгорток і вкладеності
Одна поведінка в різних дизайнах із доступністюHeadless-хук із prop gettersСемантика й події задаються один раз
Combobox, menu, dialog, date picker або data gridГотова headless-бібліотекаДеталі клавіатури й скринрідерів складні
Щоб компонент вирішував, коли, де й скільки разів рендерити UIRender propХук повертає дані, а не рендерить рядки
Додати поведінку класу або компоненту, який не можна редагуватиТонкий HOC поверх хукаКласи не викликають хуки; оригінальний компонент не змінюється
Перехопити помилки чи надати context піддеревуКомпонент — boundary або providerХук не обгортає дітей; error boundaries мають компонентну межу
Використати логіку лише в одному місціПоки без абстракціїДублювання іноді допустиме; абстрагуйте за другої реальної потреби

HOC і render prop у таблиці не є суперниками хука: вони можуть бути тонкими адаптерами поверх нього. Саме це пропонує наступна вправа.

Помилки й обмеження

  • Передчасна абстракція. Headless-хук з одним користувачем може лише ускладнити код. React допускає дублювання, а Кент Доддс застерігає від інверсії керування заради одного сценарію. Дочекайтеся другого справжнього дизайну.
  • Getters, які гублять обробники. Вони мають компонувати обидва обробники; споживач передає свій усередину getter, а не після spread.
  • Забуті getters. Застосунок не падає, але перестає оголошувати поточну сторінку. Це знаходять role-based тести, а не лише візуальне рев’ю.
  • Змішування керованого й некерованого режимів. Оберіть режим на весь час життя компонента. React попереджає, коли input переходить між undefined і рядком (документація); page={undefined} створює аналогічну неоднозначність у usePagination.
  • Запити без захисту від перегонів. Відповіді у власному Effect можуть прийти не в тому порядку. React показує очищення й радить механізми фреймворку або клієнтський кеш.
  • Headless не означає відсутність відповідальності. Споживач може обрати неправильний елемент, пропустити назву чи розійтися у стилях. Для послідовності обгорніть headless-хуки невеликим набором оформлених компонентів.
  • Не все слід перетворювати на хук. Деякі завдання працюють із піддеревом, а не значенням. Error boundaries потребують класового компонента або пакета на кшталт react-error-boundary, а providers самі є компонентами (React Component).

Практичні висновки

  • Правила тримайте у звичайних функціях, стан — у хуку, семантику — у prop getters.
  • Нехай кожен екран володіє своєю розміткою; тестуйте її за ролями, а не CSS-класами.
  • HOC і render props використовуйте як адаптери на межах: для класів або керування моментом рендерингу.
  • Для складних віджетів починайте з headless-бібліотеки, а не з нуля.

Практика

Вправа — один хук, два адаптери

  1. Старий екран — класовий компонент, тому не може викликати хук. Перепишіть withPagination як тонкий HOC над headless-версією usePagination, що передає весь результат одним prop pagination.
  2. Створіть render-prop-компонент Pagination, який передає той самий результат у функцію children.
  3. Хто тепер володіє поведінкою в обох варіантах?

Перевірте себе

  1. Два компоненти окремо викликають usePagination({ itemCount: 95 }). Зміна сторінки в одному не змінює інший. Помилка чи властивість підходу? Як дати їм спільний стан?
  2. Чому const List = withPagination(OrderList) у тілі компонента щоразу скидає сторінку?
  3. Колега написав <button {...getNextProps()} onClick={trackClick}>. Що зламається і як це виправити?
  4. Коли render prop кращий за хук?
  5. Чи дійде ref через HOC до внутрішнього компонента у React 19?

Відповіді

Вправа.

// withPagination.jsx — an HOC as a thin adapter over the hook
import { usePagination } from './usePagination.js';

export function withPagination(Component, options = {}) {
  function WithPagination({ itemCount, ...rest }) {
    const pagination = usePagination({ ...options, itemCount });
    return <Component {...rest} pagination={pagination} />;
  }

  const name = Component.displayName || Component.name || 'Component';
  WithPagination.displayName = `withPagination(${name})`;
  return WithPagination;
}
// Pagination.jsx — a render-prop adapter over the same hook
import { usePagination } from './usePagination.js';

export function Pagination({ children, ...options }) {
  return children(usePagination(options));
}

withPagination забирає itemCount, а решту передає далі. Клас отримує this.props.pagination і може передати його в NumberedPager. Оскільки options розгортаються перед itemCount, розмір сторінки можна задати під час обгортання, а кількість елементів — окремо в кожному використанні. Поведінкою в обох адаптерах володіє хук; HOC і render prop — лише тонке пакування.

Перевірка себе.

  1. Це властивість підходу: спільна логіка не означає спільний стан. Викличте хук один раз у спільному батьківському компоненті або зберігайте сторінку централізовано в URL чи context і використовуйте керований режим.
  2. Кожен виклик withPagination повертає новий тип компонента. React демонтує старе піддерево та видаляє його стан. Створюйте обгортку один раз на рівні модуля; помилку знаходить react-hooks/static-components.
  3. onClick після spread перезаписує обробник хука: аналітика спрацює, а сторінка не зміниться. Потрібно getNextProps({ onClick: trackClick }), щоб виконалися обидва.
  4. Коли компонент вирішує, де, коли та скільки разів рендерити UI: віртуалізовані рядки, FlatList.renderItem, Controller React Hook Form або заміна маніпуляцій через Children і cloneElement.
  5. Так, якщо HOC є функціональним компонентом і передає решту props далі: у React 19 ref для нього — звичайний prop. Ні, якщо HOC класовий або відкидає невідомі властивості.

Джерела

Документація React

Патерни й бібліотеки

Доступність та HTML