- Published on
- 26 min read
Who Owns the Markup? HOCs, Render Props, Hooks, and Headless Components in React
- Authors
- Name
- Learna
Your orders table has a pager: numbered buttons, Previous and Next, an ellipsis when there are too many pages. Then the mobile design arrives with a compact "‹ Page 3 of 12 ›" control, and the admin area wants the same thing with yet another look. The rules don't change — which page is current, which numbers are visible, what happens on the first and last page, how a screen reader user knows where they are. Only the markup changes.
The two obvious options both age badly. Copy the logic into every new pager, and the copies drift apart: one forgets to clamp the page after a filter shrinks the list, another forgets to mark the current page for assistive technology. Or grow one configurable component with variant, showEllipsis, hideArrowsAtEdges and friends, until every new design means another prop and another branch.
React's reuse patterns — higher-order components (HOCs), render props, custom hooks, and headless components — are four answers to the same question: when behavior is shared, who owns the markup, and how does the behavior reach it? In this article you'll build the same pagination behavior all four ways, see where each one breaks, and finish with a decision guide you can use in code review.
The code in this article was run against React 19.3.1 and React DOM 19.3.1 with Vitest 5.0.5, jsdom 30.1.3, and React Testing Library 16.3.3 in October 2026. It was not tested in real browsers or with screen readers.
A component does three jobs
Before comparing patterns, it helps to separate what an interactive component actually does:
- State: what is true right now — the current page.
- Behavior: the rules and reactions — keep the page in range, decide which page numbers to show, decide what Next does on the last page — plus the semantics that make the widget usable with assistive technology, such as a labelled navigation region and a marker on the current page.
- Presentation: elements, layout, and styles.
An ordinary component bundles all three. Every pattern below keeps state and behavior in one shared place and lets each screen own its presentation. What differs is the direction of the calls (does the shared code call your UI, or does your UI call the shared code?), where the shared values are visible, and what can collide.
Kent C. Dodds describes the underlying move as inversion of control: let the abstraction do less and hand the rest to the people using it. Each pattern hands over a different amount.
Step zero: the part that needs no React
The most reusable piece of pagination is a pure function that decides which page numbers to show. No state, no hooks, no 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 controls how many pages appear on each side of the current one. A gap is only worth it when it hides at least two pages: replacing a single page with "…" saves no space and hides information. The gap markers are strings, so they can double as React keys.
Starting here is not a detour. Anything you can express as a plain function is trivial to test (no rendering, no act) and works anywhere — in any component, on the server, in another framework. The patterns that follow solve only the next problem: connecting state, the current page, to UI that someone else writes.
Pattern 1: Higher-order components — wrap the component
A higher-order component is a function that takes a component and returns a new one (legacy React docs). In 2016 the React team recommended HOCs over mixins for cross-cutting concerns such as subscribing to a data source (Mixins Considered Harmful), and for years they were one of the main ways to attach reusable behavior.
// 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 renders WithPagination, which owns the page state, slices the items, and renders your OrderList with four extra props. OrderList stays a plain component that is easy to test with ordinary props — and that is the HOC's real strength: the wrapped component doesn't change at all.
The costs show up once the HOC meets real code.
The contract is invisible. Reading OrderList, nothing tells you where page and onPageChange come from. You have to find where it was wrapped — often in another file — and then read the HOC.
Props collide silently. Suppose a CMS screen already uses a prop called page for the document being edited:
// 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.
The HOC spreads ...rest first and sets page afterwards, so the HOC wins and the heading loses its title. Swap the order and the title survives, but the component no longer receives the current page number. Nothing warns you either way, and every additional HOC in a stack is another chance to overwrite someone else's prop.
Configuration is frozen at definition time. pageSize: 20 is decided once, when the module loads. If one screen needs a different page size, the HOC has to read yet another prop and strip it before passing the rest down — more hidden contract.
The tree gets deeper. Every HOC adds a component layer. When the React team introduced hooks, they pointed at the "wrapper hell" of HOCs, render props, providers, and consumers that a typical app shows in DevTools (Introducing Hooks). A displayName such as withPagination(OrderList) at least keeps DevTools readable, which is why the example sets one.
Housekeeping you must remember. Static methods of the wrapped component aren't copied to the wrapper; the legacy docs point to hoist-non-react-statics for that. And never create an HOC during render:
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 });
// ...
}
Each render produces a different component type, so React unmounts the old list together with its state and mounts a fresh one (Preserving and Resetting State). In the tests written for this article, ticking an unrelated "Dense rows" checkbox sent the list back to page 1. The react-hooks/static-components rule from eslint-plugin-react-hooks 7.1.1 flags that line with "Cannot create components during render" (rule docs). Wrap components once, at module level.
Refs: what changed in React 19. Older guides warn that refs don't pass through HOCs and that you need forwardRef. Since React 19, function components receive ref as an ordinary prop (React 19 release post), and the forwardRef reference now says it's no longer necessary and will be deprecated in a future release (forwardRef). That's why OrderList above simply destructures ref: <PaginatedOrderList ref={listRef} items={orders} /> reaches its section, because WithPagination is a function component and ref travels inside ...rest. This was verified with React 19.3.1. It still fails if the HOC is a class component (a ref to a class points at the instance) or if the HOC drops props it doesn't recognize.
When an HOC is still the right tool: adding behavior to a component you can't or don't want to edit; serving class components, which can't call hooks — React Router's v6 FAQ shows a small withRouter wrapper built on hooks for exactly that case (React Router FAQ); and existing APIs such as React Redux's connect, which remains supported even though the docs recommend the hooks API as the default (React Redux hooks). For new code, note the banner on the legacy HOC page itself: HOCs are no longer common in modern React.
Pattern 2: Render props — the component calls your function
A render prop is a prop whose value is a function; the component calls it to find out what to render (legacy React docs). Instead of wrapping your component, the shared component calls you:
// 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>
);
}
Compared with the HOC, three things got better:
- The data flow is visible. Everything the callback gets is right there in its parameter list.
- No collisions. You destructure and name the values yourself, so the CMS's
cmsPageand the pager'spagesit side by side. - Configuration happens at render time.
pageSize={5}is an ordinary prop; another screen can pass a different value.
And three things got worse:
- The values exist only inside the callback.
RevisionsPanelcan't readpagein its own body — to sync it to the URL, prefetch the next page, or pass it to a data hook. You can't call a hook inside the callback either: it's a nested function, and the Rules of Hooks forbid hooks there —eslint-plugin-react-hooksreports such a hook as "called inside a callback". You end up lifting the state up or adding a component just to get a place where hooks are allowed. - Nesting. Combine three render-prop components and you get three levels of nested callbacks.
- A memoization caveat. An inline function is a new prop on every render. That defeats the shallow prop comparison of
React.PureComponent— the main caveat in the legacy docs — and ofmemo, which compares each prop by reference (memo). It matters only when the receiving component is memoized; measure before restructuring for it.
Render props aren't obsolete, though. They stay the right tool when a component must decide when, where, or how many times to render the caller's UI — something a hook can't do, because a hook returns data and renders nothing. React Native's FlatList calls your renderItem for the rows it renders (FlatList); React Hook Form's Controller hands a field object to your render function (Controller); and react.dev suggests a render prop as an alternative to fragile Children and cloneElement tricks (Children, cloneElement). Kent C. Dodds made the same argument shortly after hooks were announced: for this kind of rendering control, render props are here to stay (Kent C. Dodds, 2018).
Pattern 3: Custom hooks — your component calls the behavior
Hooks shipped in React 16.8 in February 2019, and the React team was explicit about why: React lacked a good primitive for sharing stateful logic, and HOCs and render props made you restructure your components to use them (Introducing Hooks). A custom hook flips the direction once more: your component calls the shared code and gets plain values back.
// 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>
</>
);
}
The values now live in your component body. You can pass page to a data hook, write it to the URL, or show "Orders 21–40" in a header — no wrappers, no nesting, and hooks compose like ordinary function calls. Vue arrived at the same place: its docs recommend composables for reusing pure logic, and components only when you reuse logic and layout together (Vue: Composables).
Two facts trip people up:
- Hooks share logic, not state. Every call to a hook gets its own, independent state (Reusing Logic with Custom Hooks). If a header and a footer each call
usePagination({ itemCount: 95 }), clicking Next in the footer won't move the header. To share one page, call the hook once in a common parent and pass the result down — or keep the page in the URL or in context. - The Rules of Hooks apply. Call hooks at the top level of components and custom hooks — not in conditions, loops, or nested functions (Rules of Hooks).
Now look at what the first version leaves to its consumer. OrdersPage had to remember type="button", aria-current, the label on the nav, and what to render for the gaps. The next pager will have to remember all of it again, and sooner or later one of them won't. The hook shares state, but not semantics.
Pattern 4: Headless hooks — behavior and semantics, no markup
Juntao Qiu's article on martinfowler.com defines a headless component as one that is responsible only for logic and state and prescribes no particular UI; it's typically implemented as a hook (Headless Component). TanStack Table describes headless libraries the same way: they provide logic, state, processing, and an API, but no markup or styles (TanStack Table).
What separates a headless hook from the first version is that it also owns everything that must be identical in every UI — attributes and event handlers included — and hands it out through prop getters: functions that return the props you spread onto an element. Downshift was, by its author's account, the first library to use them (Kent C. Dodds, 2017); its README explains that the getters carry the necessary ARIA attributes while leaving you free to render whatever you like, wherever you like (Downshift). Why functions rather than plain objects? A function can merge your props with the hook's — in particular, run your onClick and the hook's instead of letting one overwrite the other.
// 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)),
}),
};
}
Here are the decisions this hook makes once, so that no UI has to make them again:
- Defaults first, your props second, handlers composed last.
getNavProps({ 'aria-label': 'Orders pages' })replaces the default label, but youronClicknever replaces the hook's:composeHandlersruns yours, then the hook's. - An escape hatch. If your handler calls
event.preventDefault(), the hook skips its own handler — for example, to ask for confirmation before switching pages while a form has unsaved edits. React Router's docs use the same convention in a customLink(upgrading from v5). - Semantics borrowed from a design system that documents them. A navigation region labelled "Pagination", "Page N" labels on the number buttons, and
aria-current="page"on the current one follow the markup of the GOV.UK Design System's pagination component (GOV.UK).aria-current="page"marks the current page within a set, and only one element of a set should carry it (MDN). The label is overridable for a reason: a page with pagers for two different lists needs a distinct label for each navigation landmark (W3C's ARIA Authoring Practices Guide, or APG: Landmark Regions). type: 'button'. Abuttoninside a form submits that form unless told otherwise (MDN: button), so a pager inside a filter form would submit it on every click. One default in the hook fixes every UI.- State as well as getters.
hasPreviousandhasNextexist because conventions differ. GOV.UK hides Previous on the first page and Next on the last; the APG keeps such buttons but disables them when users can infer they exist (APG: Developing a Keyboard Interface). A headless hook shouldn't impose one convention on every product. - Controlled or uncontrolled. Pass
pageandonPageChangeand the parent owns the state — what you need when the page lives in the URL or drives a server request. Downshift's README calls the same idea "control props". Omit them and the hook manages the page itself. - Getters aren't hooks. They're plain functions, so calling
getPageProps(item)insidemapis fine.
Two UIs, one hook:
// 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} />
</>
);
}
Because the state lives in OrdersPage, switching between the two pagers keeps the current page. const Pager = compact ? CompactPager : NumberedPager is fine: it picks one of two components defined at module level rather than creating a new one during render.
Two usage notes. First, pass your handlers into the 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>
Second, here is the controlled mode in practice:
// 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} />;
}
To test a headless hook, test it where users meet it: render each UI and query by role and accessible name, such as getByRole('button', { name: 'Page 3' }). If someone forgets to spread a getter, those queries fail — exactly the regression you want to catch. The pure getPageItems gets cheap, table-driven unit tests.
Side by side
| HOC | Render prop | Custom hook | Headless hook | |
|---|---|---|---|---|
| Who calls whom | The wrapper renders your component | The component calls your function | Your component calls the hook | Your component calls the hook and spreads its getters |
| Where shared values are visible | In the wrapped component's props | Only inside the callback | Anywhere in your component | Anywhere in your component |
| Who owns the markup | You, but the wrapper decides your props | You, inside the callback | You | You; the hook supplies attributes and handlers |
| Typical failure | Silent prop collisions; state loss when created during render | Values trapped in the callback; nesting | Expecting two calls to share state | A forgotten getter silently breaks semantics |
| Still a good fit for | Class components, components you can't edit, existing APIs | Rendering the component schedules: rows, items, slots | Most new logic reuse | One behavior, many designs |
Shipping headless behavior: hooks or unstyled components
Production libraries deliver headless behavior in two shapes.
Hooks with prop getters: Downshift (useSelect, useCombobox, useMultipleSelection), React Aria's hooks, and TanStack Table. You render every element; the library supplies state, attributes, and handlers.
Unstyled components: Radix Primitives, Headless UI, React Aria Components, and Base UI. The parts of a widget — trigger, list, item — are separate components that share state through context (the compound-components pattern). They render the structure and leave every visual decision to you. Radix, for example, ships without styles and takes care of roles, ARIA attributes, focus management, and keyboard navigation, following WAI-ARIA patterns where possible (Radix Primitives). Headless UI presents itself as unstyled, accessible components designed to pair with Tailwind CSS (Headless UI), and Base UI as an unstyled component library for accessible interfaces (Base UI).
The trade-off mirrors this whole article. Components mean less wiring and a structure you can't break by forgetting a getter. Hooks give you full control over elements and nesting in exchange for more responsibility. React Aria offers both in one library: high-level components and a lower-level, hook-based API for when you need more control (React Aria). Many teams stack the layers: a headless library at the bottom, styled design-system components in the middle, product screens on top.
Two practical notes:
- For complex widgets, use a library. A combobox, menu, dialog, or date picker needs keyboard handling, focus management, and screen-reader behavior that are easy to get subtly wrong. Qiu's article builds a dropdown from scratch to teach the pattern and still recommends mature libraries for production. Write your own headless hooks for your domain behavior: pagination rules, a booking flow, a permission-aware toolbar.
- Server Components and dot notation. Compound components are often exposed as
Tabs.ListorTabs.Panel. A Server Component can't reach into a client component like that: React's error #591 says you cannot "dot into" a client module from a server component (React error 591), and Mantine documents the same limitation for its compound components (Mantine help). Prefer libraries — and write your own exports — that also expose each part as a separate named export.
For reference, the versions on npm on October 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.
Choosing a pattern
| If you need… | Reach for… | Because… |
|---|---|---|
| To reuse stateful logic in new code | A custom hook | The values stay in your component; nothing to wrap or nest |
| One behavior in several designs, accessibility included | A headless hook with prop getters | Semantics and event wiring are written once |
| A combobox, menu, dialog, date picker, or data grid | An existing headless library | The keyboard and screen-reader details are large and subtle |
| The component to decide when, where, or how often your UI renders | A render prop | A hook returns data; it can't render a row for you |
| Behavior in a class component or a component you can't edit | A thin HOC on top of a hook | Class components can't call hooks; the wrapped component stays unchanged |
| To catch errors or provide context for a subtree | A component: a boundary or a provider | A hook can't wrap children, and error boundaries are still class components (Component) |
| The logic in one place only | Nothing yet | Some duplication is fine (react.dev); abstract at the second real use |
Notice that the HOC and render-prop rows aren't rivals of the hook. Both can be thin adapters around a hook — the exercise below asks you to build them.
Mistakes and limits
- Abstracting too early. A headless hook with a single consumer is just indirection. react.dev says outright that some duplication is fine, and Kent C. Dodds warns that adding inversion of control for a single use case only makes code more complicated (Inversion of Control). Wait for the second real design.
- Getters that drop handlers. A getter must compose the consumer's handlers with its own, and consumers must pass handlers into getters rather than after the spread.
- Forgotten getters. Nothing crashes; the pager just stops announcing the current page. Role-based tests catch this; a visual review doesn't.
- Switching between controlled and uncontrolled. Decide once, for the component's lifetime. React warns when an input's
valueswitches betweenundefinedand a string (input troubleshooting);usePaginationhas the same ambiguity, becausepage={undefined}means "manage it yourself". - Hooks that fetch without handling races. If a homegrown hook fetches in an Effect, responses can arrive out of order. react.dev shows the cleanup you need and recommends your framework's data fetching or a client-side cache instead (useEffect: fetching data).
- Treating headless as free. The consumer now owns the markup, so the consumer can also break it: the wrong element, a missing label, inconsistent styles. If your team needs consistency, wrap headless hooks in a small set of styled components and use those in product code.
- Forcing everything into hooks. Some jobs are about a subtree, not a value. Error boundaries still have to be class components or come from a package such as
react-error-boundary, and providers are components by definition (Component).
The practical takeaway
- Put the rules in plain functions, the state in a hook, and the semantics in prop getters.
- Let every screen own its markup, and test it by role rather than by class name.
- Use HOCs and render props as adapters at the edges — class components, components that schedule rendering — not as the place where your logic lives.
- For hard widgets, start from a headless library instead of from scratch.
Practice
Exercise: one hook, two adapters
- A legacy screen is a class component, and class components can't call hooks. Rebuild
withPaginationas a thin HOC on top of the headlessusePaginationthat injects the hook's whole result as a singlepaginationprop. - Write a render-prop component,
Pagination, that passes the same result to itschildrenfunction. - Then ask yourself: in the new versions, which pattern owns the behavior?
Check yourself
- Two components each call
usePagination({ itemCount: 95 }). Clicking Next in one doesn't change the other. Bug or feature? How would you make them share a page? - Why does
const List = withPagination(OrderList)inside a component body reset the page on every render? - A teammate writes
<button {...getNextProps()} onClick={trackClick}>. What breaks, and what should they write instead? - Name a situation where a render prop is still a better fit than a hook.
- In React 19, does a
refpassed to an HOC-wrapped component reach the inner component?
Answers
Exercise.
// 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 consumes itemCount and forwards everything else, so a class component receives this.props.pagination and can pass it straight to NumberedPager. Spreading options before itemCount lets you fix a page size when you wrap the component while each usage supplies its own item count. In both adapters the hook owns the behavior; the HOC and the render prop are a few lines of packaging. That's the shape to aim for.
Check yourself.
- A feature: hooks share logic, not state, so each call has its own page. Call the hook once in a common parent and pass the result down, or keep the page in one place — a parent's state, the URL, or context — and use controlled mode.
withPaginationreturns a new component type on every call. React sees a different type at the same position, unmounts the old subtree, and discards its state. Create the wrapped component once, at module level;react-hooks/static-componentsflags the mistake.- The
onClickwritten after the spread replaces the hook's handler, so tracking works but the page never changes. Pass the handler into the getter —getNextProps({ onClick: trackClick })— and the hook runs both. - When the component decides when, where, or how often to render your UI: rows in a virtualized list,
FlatList'srenderItem, React Hook Form'sController, or as a replacement forChildrenandcloneElementtricks. - Yes, if the HOC is a function component that passes its remaining props through: since React 19,
refis a regular prop for function components. No, if the HOC is a class component or drops props it doesn't know.
Sources
React documentation
- 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 (error boundaries) — react.dev
- input (controlled and uncontrolled) — react.dev
- useEffect (fetching data) — react.dev
- Minified React error #591 — react.dev
- Higher-Order Components, Render Props, and Introducing Hooks — legacy React docs, no longer updated
- Mixins Considered Harmful — Dan Abramov, July 13, 2016
- React v16.8: The One With Hooks — React team, February 6, 2019
Patterns and libraries
- 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 and control props
- 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?
Accessibility and HTML