Render props, higher-order components and custom hooks
Hooks put shared logic inside your component; higher-order and render-prop components put it around yours.
Predict first
Before you read: predict
A chat app's conversation header is wrapped by two higher-order components:
What does the header show?
function withPresence(Component) { return function WithPresence(props) { return <Component {...props} status="online" />; };}function withDraft(Component) { return function WithDraft(props) { return <Component status="draft saved" {...props} />; };}function Header({ name, status }) { return <p>{name} · {status}</p>;}const ChatHeader = withDraft(withPresence(Header)); // the conversation screen renders:<ChatHeader name="Maya" status="away" />The 30-second version
A higher-order component and a render-prop component share stateful logic by wrapping: each adds a component to the tree, and a wrapper's props reach the inner component unseen. A custom hook shares the same logic as a call inside your component, so it is the default.
Hooks put shared logic inside your component; higher-order and render-prop components put it around yours.
How it works
Step 1 of 10
Analogy: An archery tournament (a club's shooting line)
| On the shooting line | In code |
|---|---|
| a target judge, then the head judge, each write a score for the same end on an archer's sheet; the score written last is the one that counts, and the sheet does not say who wrote it | stacked HOCs and their injected props (steps 3–4) |
| the range master's whistle decides when each end is shot and calls out its number, as many ends as the round needs; how the archer shoots each one is hers | a render prop: the provider calls, the caller writes (steps 7–8) |
| an archer training alone keeps the round's timings herself and blows her own whistle | a custom hook: the logic runs in the caller (steps 5–6) |
In real terms: before you add a wrapper, ask who decides the markup and who decides when it renders; if your component decides both, call a hook.
Breaks down when: an archer can ask the judges who wrote her score; a component cannot see which wrapper set its prop (step 3).
Contrasting case: a component that only formats a date needs none of the three: a plain function without use does, because it holds no state.
Examples, run for real
4.1 ExamHall EXH-4110 and MR !4410: two wrappers, one prop
EXH-4110: ExamHall's answer screen was wrapped by two HOCs, and both passed a status:
function withServerDeadline(Component) {
return function WithServerDeadline({ clock, deadlineAt, ...props }) {
const [now, setNow] = useState(() => clock.now());
useEffect(() => clock.subscribe(() => setNow(clock.now())), [clock]);
return <Component {...props} status={now >= deadlineAt ? 'closed' : 'open'} />;
};
}
function withAutosave(Component) {
return function WithAutosave(props) {
const [saved, setSaved] = useState(0);
return <Component status={saved ? 'saved' : 'editing'} {...props} onSave={() => setSaved((n) => n + 1)} />;
};
}
function AnswerScreen({ status, onSave }) {
return <form><textarea disabled={status === 'closed'} /><button type="button" onClick={onSave}>Save</button><output>{status}</output></form>;
}
const Screen = withAutosave(withServerDeadline(AnswerScreen));
EXH-4110 as shipped: components 3 (WithAutosave > WithServerDeadline > AnswerScreen) · start "open" · after Save "open" · at the deadline "closed", textarea disabled
The deadline's closed reached the textarea, but autosave's saved never did: WithAutosave writes its status before {...props}, and WithServerDeadline, nearer the screen, writes its own after them (steps 3–4). MR !4410 turns both into hooks, and the screen names what each returns:
function useServerDeadline(clock, deadlineAt) {
const [now, setNow] = useState(() => clock.now());
useEffect(() => clock.subscribe(() => setNow(clock.now())), [clock]);
return Math.max(0, deadlineAt - now);
}
function useAutosave() {
const [saved, setSaved] = useState(0);
return { saved: saved > 0, save: () => setSaved((n) => n + 1) };
}
function AnswerScreen({ clock, deadlineAt }) {
const left = useServerDeadline(clock, deadlineAt);
const draft = useAutosave();
return <form><textarea disabled={left === 0} /><button type="button" onClick={draft.save}>Save</button><output>{left === 0 ? 'closed' : draft.saved ? 'saved' : `${left}s left`}</output></form>;
}
MR !4410: components 1 (AnswerScreen) · start "60s left" · after Save "saved" · at the deadline "closed", textarea disabled
One component replaces three, and each value has one named source (steps 2, 5, 10). A reviewer now reads both sources in AnswerScreen's first two lines; before, she had to open both wrappers and compare where each wrote {...props}.
4.2 A render prop that passes nothing useful becomes children
ExamHall's rules panel can be folded away. It took a render prop, and the rules screen passed a function that ignored the panel's argument; a refactor gives the panel children instead:
function Collapsible({ title, render }) {
const [open, setOpen] = useState(true);
return <section><button type="button" onClick={() => setOpen(!open)}>{title}</button>{open && render(open)}</section>;
}
function FoldingPanel({ title, children }) {
const [open, setOpen] = useState(true);
return <section><button type="button" onClick={() => setOpen(!open)}>{title}</button>{open && children}</section>;
}
function ExamRules() {
return <p>Calculators are not allowed.</p>;
}
// before renders:
<Collapsible title="Rules" render={() => <ExamRules />} />
// after renders:
<FoldingPanel title="Rules"><ExamRules /></FoldingPanel>
4.2 before (a render prop): render called 1 time, with (true); the function passed declares 0 parameters · shows <button> Rules, <p> Calculators are not allowed. · after a click on the title <button> Rules
4.2 after (children): shows <button> Rules, <p> Calculators are not allowed. · after a click on the title <button> Rules
The function decided nothing the screen could not decide alone, so children do the same job without one (steps 8 and 10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A chat app's MR deletes withUnread(Component), a HOC that passed an unread count, and adds a hook in its place:
The four consumers above are the MR's conversions.
Which one fails when it renders?
function useUnread(channelId) { const [count] = useState(() => unreadStore.get(channelId)); return count;} class InboxBadge extends Component { render() { return <b>{useUnread('inbox')}</b>; }} const PinnedRow = memo(function PinnedRow({ id }) { const unread = useUnread(id); return <li>{id} ({unread})</li>;}); function DmHeader({ me, them }) { const mine = useUnread(me); const theirs = useUnread(them); return <h3>{mine} / {theirs}</h3>;} function useTabTitle() { return `Chat (${useUnread('general')})`;}function TabTitle() { return <span>{useTabTitle()}</span>;}Question 2 of 8
A chat screen shows "Maya is typing" with dots that change each time the typing feed fires. Two versions:
Each version is mounted by itself and the typing feed fires 3 times.
In which version does MessageList render again each time the feed fires?
function useTypingDots() { const [n, setN] = useState(0); useEffect(() => typingFeed.subscribe(() => setN((x) => x + 1)), []); return '.'.repeat(n % 4);}function TypingDots({ render }) { return render(useTypingDots());}function MessageList() { return <ul><li>Maya: see you at 6?</li></ul>;} function ChatScreenV1() { return ( <main> <MessageList /> <TypingDots render={(dots) => <em>Maya is typing{dots}</em>} /> </main> );} function ChatScreenV2() { const dots = useTypingDots(); return ( <main> <MessageList /> <em>Maya is typing{dots}</em> </main> );}Question 3 of 8
A chat app's MessageList decides which messages are on screen and which one is the reply target. The channel view, a thread and search results each need their own markup for a message, and that markup depends on whether the message is the reply target.
Which API should MessageList offer?
Question 4 of 8
clock.now() returns the server time in seconds and clock.subscribe(fn) returns an unsubscribe function.
Name at least five tests, each with what it sets up and what it expects.
Question 5 of 8
Now write it, in 10 minutes, from a blank file that imports useState and useEffect from react and exports useServerDeadline.
Question 6 of 8
A chat app's message composer is wrapped by two higher-order components and drawn inside a render-prop component. An MR turns withAttachments into a hook, useAttachments:
Each version is mounted by itself. Answer with version 1's count, then version 2's.
How many components does React hold for each, from Connection down to the component that draws the <p>, both included?
function withEmoji(Component) { return function WithEmoji(props) { return <Component {...props} emoji="classic" />; };}function withAttachments(Component) { return function WithAttachments(props) { const [files, setFiles] = useState([]); return <Component {...props} files={files} onAttach={setFiles} />; };}function useAttachments() { const [files, setFiles] = useState([]); return [files, setFiles];}function Connection({ render }) { const [online] = useState(true); return render(online);}function Composer({ emoji, files, online }) { return <p>{online ? 'online' : 'offline'} · {emoji} · {files.length} files</p>;}function HookComposer({ emoji, online }) { const [files] = useAttachments(); return <p>{online ? 'online' : 'offline'} · {emoji} · {files.length} files</p>;}const ComposerV1 = withEmoji(withAttachments(Composer));const ComposerV2 = withEmoji(HookComposer); // version 1 renders:<Connection render={(online) => <ComposerV1 online={online} />} />// version 2 renders:<Connection render={(online) => <ComposerV2 online={online} />} />Question 7 of 8
A video player's stats page calls one custom hook in two components:
StatsPage is mounted. Then the video feed reports 2400, the audio feed reports 128 and the video feed reports 3100, one event at a time.
How many times have Sparkline and AudioStats rendered, in that order, the first render included?
function useBitrate(feed) { const [kbps, setKbps] = useState(0); useEffect(() => feed.subscribe(setKbps), [feed]); return kbps;}function Sparkline() { return <i>▁▃▅▇</i>;}function VideoStats() { const kbps = useBitrate(videoFeed); return <p>video {kbps} kbps <Sparkline /></p>;}function AudioStats() { const kbps = useBitrate(audioFeed); return <p>audio {kbps} kbps</p>;}function VideoPanel() { return <section><VideoStats /></section>;}function StatsPage() { return ( <main> <VideoPanel /> <AudioStats /> </main> );}Question 8 of 8
A box office's seat timer is wrapped by withPromo, which still injects the promo's countdown, while the hold's countdown now comes from a hook:
For B7 the hold store has 240 seconds left and the promo store 90. The page should show Seat B7: 240s left, the hold's countdown.
Which SeatTimer does that?
function withPromo(Component) { return function WithPromo(props) { return <Component {...props} remaining={promos.secondsLeft(props.seatId)} />; };}function useHold(seatId) { const [remaining] = useState(() => holds.secondsLeft(seatId)); return remaining;}const PromoSeat = withPromo(SeatTimer); // the seat page renders:<PromoSeat seatId="B7" />Go deeper
Pro tips
- Name hooks after what they give, and keep JSX out of them.
useServerDeadlinereturns seconds, not an element; a hook that returns elements is a component in disguise, and a component defined inside a hook is a new type on every render (react-reconciliation-keysstep 4). When: reviewing a hook's return value. Verify: nothing to run here; this is the curriculum row's pro tip, and it keys nothing. Status: ADVICE. - Read a HOC's spread before you trust a prop. Find
{...props}in each wrapper: a prop written after it overrides the caller. When: a component shows a value the caller did not pass (step 4). Verify (RAN,tips.mjs):tip 2 caller passes status="free"; wrapper writes status="busy" after {...props}: shows "busy" · before {...props}: shows "free"
Source
A web page can change, so every claim from these pages is labelled as of 2026-10-13; every React behaviour this lesson grades is also executed on React 19.3.0.
- react.dev, Reusing Logic with Custom Hooks (as of 2026-10-13), the row's first source: https://react.dev/learn/reusing-logic-with-custom-hooks (
react-reusing-logic-custom-hooks.txt, the article from its first paragraph). Steps 1, 2 and 6 rest on it: "Think of custom Hooks’ code as part of your component’s body!"; P3's C quotes it. - react.dev, Children (as of 2026-10-13), the row's second source, whose "Alternatives" section lists the render prop: https://react.dev/reference/react/Children (
react-children.txt, the article from its title). Steps 1 and 8 rest on it: "A prop like renderContent is called a render prop because it is a prop that specifies how to render a piece of the user interface."; P3's key, A and B quote it (B since F15 ruling 28).
Related lessons
- Read first: Effects as synchronization
- Read first: Composition over inheritance, and the fragile base class
- Related: Render and commit
- Related: Single responsibility as one reason to change
- Related: Reconciliation, identity and keys
- Related: Pagination and list virtualization
- Related: Compound components: parts that share a Root's state
Checked against the tools and docs as of 2026-10-13.