- Опубліковано
- 21 хв читання
Хто володіє розміткою? HOC, render props, хуки та headless-компоненти в React
- Автори
- Ім’я
- 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 призначені для повторного використання логіки, а компоненти — коли разом повторюються логіка та розмітка.
Два факти часто спричиняють плутанину:
- Хуки діляться логікою, а не станом. Кожен виклик має незалежний стан (документація React). Якщо header і footer окремо викличуть
usePagination({ itemCount: 95 }), зміна одного не змінить інший. Для спільної сторінки викличте хук один раз у спільному предку й передайте результат або зберігайте стан у URL чи context. - Правила хуків залишаються чинними. Викликайте їх на верхньому рівні компонента або іншого хука, не у вкладених функціях, умовах чи циклах (правила).
Але користувач першої версії ще має пам’ятати 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-тестів.
Порівняння
| HOC | Render 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-бібліотека | Деталі клавіатури й скринрідерів складні |
| Щоб компонент вирішував, коли, де й скільки разів рендерити UI | Render 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-бібліотеки, а не з нуля.
Практика
Вправа — один хук, два адаптери
- Старий екран — класовий компонент, тому не може викликати хук. Перепишіть
withPaginationяк тонкий HOC над headless-версієюusePagination, що передає весь результат одним proppagination. - Створіть render-prop-компонент
Pagination, який передає той самий результат у функціюchildren. - Хто тепер володіє поведінкою в обох варіантах?
Перевірте себе
- Два компоненти окремо викликають
usePagination({ itemCount: 95 }). Зміна сторінки в одному не змінює інший. Помилка чи властивість підходу? Як дати їм спільний стан? - Чому
const List = withPagination(OrderList)у тілі компонента щоразу скидає сторінку? - Колега написав
<button {...getNextProps()} onClick={trackClick}>. Що зламається і як це виправити? - Коли render prop кращий за хук?
- Чи дійде
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 — лише тонке пакування.
Перевірка себе.
- Це властивість підходу: спільна логіка не означає спільний стан. Викличте хук один раз у спільному батьківському компоненті або зберігайте сторінку централізовано в URL чи context і використовуйте керований режим.
- Кожен виклик
withPaginationповертає новий тип компонента. React демонтує старе піддерево та видаляє його стан. Створюйте обгортку один раз на рівні модуля; помилку знаходитьreact-hooks/static-components. onClickпісля spread перезаписує обробник хука: аналітика спрацює, а сторінка не зміниться. ПотрібноgetNextProps({ onClick: trackClick }), щоб виконалися обидва.- Коли компонент вирішує, де, коли та скільки разів рендерити UI: віртуалізовані рядки,
FlatList.renderItem,ControllerReact Hook Form або заміна маніпуляцій черезChildrenіcloneElement. - Так, якщо HOC є функціональним компонентом і передає решту props далі: у React 19
refдля нього — звичайний prop. Ні, якщо HOC класовий або відкидає невідомі властивості.
Джерела
Документація React
- Reusing Logic with Custom Hooks — react.dev
- Rules of Hooks — react.dev
- Preserving and Resetting State — react.dev
- static-components lint rule — react.dev
- React v19 — React team, December 5, 2024
- forwardRef — react.dev
- memo — react.dev
- Children and cloneElement — react.dev
- Component (межі помилок) — react.dev
- input (керований і некерований режими) — react.dev
- useEffect (завантаження даних) — react.dev
- Minified React error #591 — react.dev
- Higher-Order Components, Render Props, and Introducing Hooks — попередня документація React, яку більше не оновлюють
- Mixins Considered Harmful — Dan Abramov, July 13, 2016
- React v16.8: The One With Hooks — React team, February 6, 2019
Патерни й бібліотеки
- Headless Component: a pattern for composing React UIs — Juntao Qiu, martinfowler.com, November 7, 2023
- Inversion of Control — Kent C. Dodds, November 18, 2019
- How to give rendering control to users with prop getters — Kent C. Dodds, October 2, 2017
- React Hooks: What's going to happen to render props? — Kent C. Dodds, December 10, 2018
- Downshift README — prop getters та керовані властивості
- TanStack Table: Introduction
- React Aria, Radix Primitives, Headless UI, Base UI
- React Router v6 FAQ and Upgrading from v5
- React Redux: Hooks
- React Native: FlatList
- React Hook Form: Controller
- Vue: Composables
- Mantine: Can I use Mantine components as server components?
Доступність та HTML