Skip to content

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?

Code for this question
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" />
Pick one

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

Three ways. A higher-order component (HOC) returns a component rendering yours; a render prop is a function prop returning UI; a custom hook calls hooks.

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?

Code for this question
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>;}
Pick one

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?

Code for this question
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>  );}
Pick one

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?

Pick one

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?

Code for this question
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} />} />
Pick one

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?

Code for this question
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>  );}
Pick one

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?

Code for this question
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" />
Pick one

Go deeper

Pro tips
  1. Name hooks after what they give, and keep JSX out of them. useServerDeadline returns 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-keys step 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.
  2. 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).

Checked against the tools and docs as of 2026-10-13.