A render prop is a function you pass to a component. The component calls it with its own data, and your function says what to show. Learn when to use one, and when a hook is better.
In Part 16 we moved shared code into custom hooks. In Part 38 we built parts that share state through context. This part shows an older way to share code. It is still useful in a few places.
The idea is simple. You pass a function to a component. The component keeps some data, like the place of your mouse, or whether something is on. When it renders, it calls your function and gives it that data. Your function returns the JSX to show. A function passed like this is called a render prop.
We’ll build three of them: a toggle, a mouse tracker and a data loader. Then we’ll write the same three as hooks, and compare. We’ll also see where render props still win, and what they cost.
Try this first
Read this code. Don’t press Run yet.
import { useState, type ReactNode } from 'react'
type ToggleState = { on: boolean; toggle: () => void }
function Toggle({ render }: { render: (state: ToggleState) => ReactNode }) {
const [on, setOn] = useState(false)
console.log('Toggle renders, on is', on)
return render({ on, toggle: () => setOn(!on) })
}
export default function App() {
console.log('App renders')
return (
<Toggle
render={({ on, toggle }) => (
<button onClick={toggle}>The light is {on ? 'on' : 'off'}</button>
)}
/>
)
}
App renders
App renders
Toggle renders, on is false
Toggle renders, on is false
Toggle renders, on is true
Toggle renders, on is true
App has no state. It only passes a function to Toggle, as a prop called render.
Make a guess. When you click the button, the text changes. Will the Console show “App renders” again?
Press Run, then click the button.
The text changes to “The light is on”. But “App renders” doesn’t come back. Only Toggle renders again. Each line shows twice because of Strict Mode, which runs each component twice while you develop. Part 2 explained why.
So the state lives in Toggle. Yet the button, its text and its look all came from App. That’s what a render prop does.
What a render prop is
render={({ on, toggle }) => ...} passes a function as a prop. A prop can hold any value: text, a number, an object, or a function. You met function props in Part 6, with onClick and onX props.
The difference is in when the function runs, and what it gives back.
onClickruns later, when the user clicks. It gives back nothing.renderruns whileTogglerenders. It gives back JSX, andTogglereturns that JSX.
React’s old docs give a short definition: “a render prop is a function prop that a component uses to know what to render”. React’s current docs say the same. They call it “a regular prop which happens to be a function”.
There’s nothing special for React here. React doesn’t know render is a render prop. Toggle calls it like any other function.
The parent owns the data. Your function says what to show. Press play, or step through it.
Here are the same steps in words.
Apprenders. It makes a<Toggle>element, with a function in itsrenderprop. The function doesn’t run yet.- React renders
Toggle.Togglehas the state:onisfalse. Togglecalls your function, and passes its data:on, and atogglefunction that flips it.- Your function returns JSX: a button that says “The light is off”.
Togglereturns that JSX. React puts the button on the page.
When you click, toggle calls setOn(!on). React renders Toggle again, and Toggle calls your function again, now with on as true. App doesn’t render. Its function is still the one React got the first time.
An everyday example
Think of a kitchen and a note. The kitchen has the food. You send a note that says how to put the food on the plate. The kitchen cooks, then follows your note.
Toggle is the kitchen. It owns the data. Your function is the note. It decides what the data looks like.
The exact version
A kitchen reads a note once. A render prop is called every time the component renders. So it runs again and again, and it must follow the rules for rendering. It must be pure: the same data in, the same JSX out. And it must not change anything outside. Part 11 explained that rule.
Your function also runs as part of Toggle‘s render, not App‘s. That matters for hooks, as Common mistakes shows.
Children as a function
The prop doesn’t have to be called render. Any prop can hold the function. A common choice is children.
As Part 3 showed, whatever you put between a component’s tags becomes its children prop. Usually that is JSX. But it can be a function too:
import { useState, type ReactNode } from 'react'
type ToggleState = { on: boolean; toggle: () => void }
function Toggle({ children }: { children: (state: ToggleState) => ReactNode }) {
const [on, setOn] = useState(false)
return children({ on, toggle: () => setOn(!on) })
}
export default function App() {
return (
<div>
<Toggle>
{({ on, toggle }) => (
<button onClick={toggle}>The light is {on ? 'on' : 'off'}</button>
)}
</Toggle>
<Toggle>
{({ on, toggle }) => (
<div>
<button onClick={toggle}>{on ? 'Hide' : 'Show'} the details</button>
{on && <p>Ships in 2 days.</p>}
</div>
)}
</Toggle>
</div>
)
}
Run it, and press “Show the details”. The text appears. The light stays off.
The same Toggle gives two different results. One is a single button. The other is a button and a paragraph. Each <Toggle> has its own on, so the two don’t affect each other.
The function between the tags is a normal JSX expression in curly braces. <Toggle>{fn}</Toggle> means the same as <Toggle children={fn} />. React’s old docs show both forms.
Which name should you use? There is no rule.
childrenreads well when there is one function, and it fills the whole component.- A named prop like
renderorrenderItemsays what the function is for. A component can take several, likerenderHeaderandrenderRow.
The types
In TypeScript, the prop’s type is a function type. It says what goes in. And it says that what comes out is a ReactNode, which is anything React can show:
import type { ReactNode } from 'react'
type ToggleState = { on: boolean; toggle: () => void }
type RenderToggle = {
render: (state: ToggleState) => ReactNode
}
type ToggleWithChildren = {
children: (state: ToggleState) => ReactNode
}
The two types are the same, apart from the name of the prop. TypeScript then checks both sides. Inside your function, it knows that on is a boolean. And Toggle must call the function with the right object.
The function can also take more than one argument. React’s docs use renderItem(item, isHighlighted). We pass one object here, { on, toggle }. Then the caller takes what it needs, by name, in any order. That is the same choice as for a hook’s return value in Part 16.
A mouse tracker
Now a render prop with data that changes often: where the pointer is. A pointer is a mouse, a pen or a finger on a touch screen.
import { useState, type ReactNode } from 'react'
type Point = { x: number; y: number }
function MouseTracker({ render }: { render: (point: Point) => ReactNode }) {
const [point, setPoint] = useState<Point>({ x: 0, y: 0 })
return (
<div
onPointerMove={e => {
const box = e.currentTarget.getBoundingClientRect()
setPoint({ x: Math.round(e.clientX - box.left), y: Math.round(e.clientY - box.top) })
}}
style={{ position: 'relative', height: 120, outline: '2px dashed gray', margin: 8, touchAction: 'none' }}
>
{render(point)}
</div>
)
}
export default function App() {
return (
<div>
<MouseTracker render={point => <p>The pointer is at {point.x}, {point.y}</p>} />
<MouseTracker
render={point => (
<div
style={{
position: 'absolute',
left: point.x - 6,
top: point.y - 6,
width: 12,
height: 12,
borderRadius: 6,
background: 'tomato',
}}
/>
)}
/>
</div>
)
}
Run it, and move your mouse over the two boxes. On a phone, drag your finger inside a box. The first box shows numbers. The second box draws a red dot that follows you.
Here is what the new parts do.
onPointerMoveruns each time the pointer moves over the box. MDN is a set of web guides that many developers use. It says this event fires when a pointer moves to a new place.e.clientXande.clientYsay where the pointer is, in pixels. They count from the top left corner of the window.e.currentTargetis the box itself. We take away the box’s own corner, fromgetBoundingClientRect(). So the numbers count from the box’s top left corner.- The dashed line is drawn with
outline, notborder. A border takes up room inside the box. Then the dot’sleftandtopwould start 2 pixels in from the box’s edge. Anoutlineline takes up no room, so the two match.margin: 8keeps the dashed lines of the two boxes apart. touchAction: 'none'tells the browser not to move the page when a finger moves in the box. MDN saysnoneturns off all of the browser’s own finger moves on that box. That includes moving two fingers apart to make the page bigger. Keep such boxes small. Without it, the browser may use a finger move to scroll the page. MDN says the event stops when the browser takes over the pointer like that.
MouseTracker doesn’t know what you will draw. It only keeps the point and calls render(point). We tried it in two real browsers, Chromium and WebKit. We moved the mouse to 120 pixels across and 40 down in each box. In Chromium, the first box showed “The pointer is at 120, 40”, and the dot’s middle sat on the pointer. WebKit showed 39 for the second number, one pixel less, and the dot was one pixel higher. The dot’s left and top are 6 less than the point, so its middle sits on the point. Our quick test page, jsdom, has no real layout. It puts every box at 0, 0, so there the numbers are just e.clientX and e.clientY.
This is the example React’s old docs used to teach render props. Their version listens for onMouseMove and draws a cat that chases the mouse.
A data loader
A render prop can also hand over data from a server. Here DataLoader takes a url. It gives your function a result: loading, an error, or the data.
There is no real network in the playground. So fakeFetch pretends to be a server. It waits 800 ms, then answers.
import { useEffect, useState, type ReactNode } from 'react'
type Result =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'done'; data: string }
const pages: Record<string, string> = {
'/weather': 'Sunny, 24 degrees',
'/news': 'The library opens at 9 today',
}
// A fake server. It waits 800 ms, then answers.
function fakeFetch(url: string): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (url in pages) resolve(pages[url])
else reject(new Error('Nothing at ' + url))
}, 800)
})
}
function DataLoader({ url, children }: { url: string; children: (result: Result) => ReactNode }) {
// The answer, and the url it belongs to.
const [answer, setAnswer] = useState<{ url: string; result: Result } | null>(null)
useEffect(() => {
let ignore = false
fakeFetch(url).then(
data => {
if (!ignore) setAnswer({ url, result: { status: 'done', data } })
},
error => {
if (!ignore) setAnswer({ url, result: { status: 'error', message: String(error) } })
},
)
return () => {
ignore = true
}
}, [url])
const result: Result = answer && answer.url === url ? answer.result : { status: 'loading' }
return children(result)
}
export default function App() {
const [url, setUrl] = useState('/weather')
return (
<div>
<button onClick={() => setUrl('/weather')}>Weather</button>
<button onClick={() => setUrl('/news')}>News</button>
<button onClick={() => setUrl('/sports')}>Sports</button>
<DataLoader url={url}>
{result => {
if (result.status === 'loading') return <p>Loading {url}...</p>
if (result.status === 'error') return <p>Sorry. {result.message}</p>
return <p>{url}: {result.data}</p>
}}
</DataLoader>
</div>
)
}
Run it. “Loading /weather…” shows first, then the weather. Press “Sports”. The fake server has no page for it, so you see the error.
The inside of DataLoader works like Part 16’s useUser hook, moved into a component. The ignore flag stops an old, slow answer from replacing a newer one. Keeping the url with each answer lets it say “loading” as soon as the url changes. Part 16 explained both.
Your function is longer this time. It has if statements, so it uses curly braces and return. That is fine. It is a normal function, called while DataLoader renders.
Why render props were popular
Before hooks, a function component couldn’t have state. You needed a class component. That is an older way to write a component, with a class instead of a function. Classes have no hooks. So there was no easy way to share code that used state.
React’s old docs name two patterns that tried to fill that gap. One was the render prop. The other was the higher-order component, or HOC, which Part 41 covers. React’s old docs taught render props with this mouse example. They listed libraries that used them, such as React Router and Downshift.
Then React 16.8 added hooks, in February 2019. React’s blog said that with hooks, you can “build your own Hooks to share reusable stateful logic”. That was the job render props had been doing.
React’s old page on hooks names the problem with the old patterns. To use them, you have to change the shape of your components. It also says that in a typical app, you would find a “wrapper hell” of components. Wrapper hell means layer on layer of components that only wrap other components.
React’s old page on render props now starts with this: “Render props are used in modern React, but aren’t very common.” And: “For many cases, they have been replaced by custom Hooks.”
The same three, as hooks
Let’s compare. Here are our three render props as hooks: useToggle, usePointer and useData.
The app below shows both ways. WithRenderProps uses all three render props at once. WithHooks uses the three hooks. Look at how each one is shaped.
To keep the app short, useData has no error case. Its fake server always answers.
Look at the render prop components, too. Each one is now very short. It calls the hook, and passes what it got to your function.
import { useEffect, useState, type PointerEvent, type ReactNode } from 'react'
type Point = { x: number; y: number }
type Result = { status: 'loading' } | { status: 'done'; data: string }
// A fake server. It waits 800 ms, then answers.
function fakeFetch(url: string): Promise<string> {
return new Promise(resolve => setTimeout(() => resolve('Sunny at ' + url), 800))
}
// --- The hooks ---
function useToggle() {
const [on, setOn] = useState(false)
return { on, toggle: () => setOn(!on) }
}
function usePointer() {
const [point, setPoint] = useState<Point>({ x: 0, y: 0 })
function onPointerMove(e: PointerEvent<HTMLDivElement>) {
const box = e.currentTarget.getBoundingClientRect()
setPoint({ x: Math.round(e.clientX - box.left), y: Math.round(e.clientY - box.top) })
}
return { point, onPointerMove }
}
function useData(url: string): Result {
const [answer, setAnswer] = useState<{ url: string; data: string } | null>(null)
useEffect(() => {
let ignore = false
fakeFetch(url).then(data => {
if (!ignore) setAnswer({ url, data })
})
return () => {
ignore = true
}
}, [url])
return answer && answer.url === url ? { status: 'done', data: answer.data } : { status: 'loading' }
}
// --- The render prop components, made from the hooks ---
function Toggle({ children }: { children: (state: ReturnType<typeof useToggle>) => ReactNode }) {
const state = useToggle()
return children(state)
}
function MouseTracker({ render }: { render: (point: Point) => ReactNode }) {
const { point, onPointerMove } = usePointer()
return (
<div onPointerMove={onPointerMove} style={{ touchAction: 'none' }}>
{render(point)}
</div>
)
}
function DataLoader({ url, children }: { url: string; children: (result: Result) => ReactNode }) {
const result = useData(url)
return children(result)
}
function WithRenderProps() {
return (
<DataLoader url="/park">
{weather => (
<Toggle>
{({ on, toggle }) => (
<MouseTracker
render={point => (
<section>
<button onClick={toggle}>{on ? 'Hide' : 'Show'} (render props)</button>
{on && <p>{weather.status === 'done' ? weather.data : 'Loading...'}</p>}
<p>Pointer: {point.x}, {point.y}</p>
</section>
)}
/>
)}
</Toggle>
)}
</DataLoader>
)
}
function WithHooks() {
const weather = useData('/park')
const { on, toggle } = useToggle()
const { point, onPointerMove } = usePointer()
return (
<section onPointerMove={onPointerMove} style={{ touchAction: 'none' }}>
<button onClick={toggle}>{on ? 'Hide' : 'Show'} (hooks)</button>
{on && <p>{weather.status === 'done' ? weather.data : 'Loading...'}</p>}
<p>Pointer: {point.x}, {point.y}</p>
</section>
)
}
export default function App() {
return (
<div>
<WithRenderProps />
<WithHooks />
</div>
)
}
Run it. Press both “Show” buttons, and move your mouse over each half. On a phone, drag a finger inside each half. They behave the same.
ReturnType<typeof useToggle> is TypeScript for “whatever useToggle returns”. So the type of Toggle‘s function stays right if the hook changes.
Now compare the two.
WithRenderProps has two costs. They are easy to mix up, so let’s keep them apart.
- In your code: each render prop adds one more function inside the last one. The JSX we care about, the
<section>, sits three functions deep. To add a fourth render prop, you wrap it all again. - In React’s tree of components: each render prop is also one more component. We counted the components React keeps between
WithRenderPropsand its<section>: 3, one for each render prop. BetweenWithHooksand its<section>, there are 0. That second cost is what React’s old hooks page calls “wrapper hell”. It says you see it when you look at an app in React DevTools.
WithHooks reads from top to bottom. Each hook is one line. The values sit next to each other, ready to use.
There is one more thing to notice. usePointer can’t put the listener on the box by itself. A hook owns no markup. So the component must do one of two things. It can pass the hook a ref, as useClickOutside did in Part 17. Or it can put the hook’s handler on its own tag, as here. Part 17 did the same with props a hook gives back. The render prop version owns its <div>, so the caller never sees the listener.
| Render prop | Custom hook | |
|---|---|---|
| Where the state lives | In the wrapper component | In your component |
| Who owns the markup | The wrapper can add its own tags | Only your component |
| Using three at once | Three functions, one inside the other | Three lines, one after the other |
| Extra components in the tree | One for each render prop | None |
| Who writes the loop, the keys and the tags of a list | The wrapper | You |
That last row is the real choice when you build a list. Let’s look at it.
When render props still make sense
React’s old page of questions about hooks says this: “There is still a place for both patterns”. It gives two cases. The first is “a virtual scroller component” with a renderItem prop. A virtual scroller is a long list that puts only the rows you can see on the page. The second is a component that “might have its own DOM structure”, which means its own tags. React’s current docs show render props too, for example on the pages for cloneElement and Children. Both offer them as a clearer way than changing the children from the outside.
A list that calls your function for each item
Here is a list that keeps one row highlighted, which means marked so it stands out. It is close to an example in React’s docs for cloneElement. The list owns the loop, the keys and the “Next” button. You own what each row looks like.
import { useState, type ReactNode } from 'react'
type Product = { id: number; title: string }
const products: Product[] = [
{ id: 1, title: 'Cabbage' },
{ id: 2, title: 'Garlic' },
{ id: 3, title: 'Apple' },
]
function List({ items, renderItem }: {
items: Product[]
renderItem: (item: Product, isHighlighted: boolean) => ReactNode
}) {
const [selected, setSelected] = useState(0)
return (
<div>
<ul>
{items.map((item, index) => (
<li key={item.id}>{renderItem(item, index === selected)}</li>
))}
</ul>
<button onClick={() => setSelected((selected + 1) % items.length)}>Next</button>
</div>
)
}
export default function App() {
return (
<List
items={products}
renderItem={(product, isHighlighted) => (
<span style={{ fontWeight: isHighlighted ? 'bold' : 'normal' }}>
{isHighlighted ? '> ' : ''}{product.title}
</span>
)}
/>
)
}
Run it and press “Next”. The mark moves down the list.
% gives what is left after dividing. So (selected + 1) % items.length goes 0, 1, 2 and then back to 0.
React’s docs say this pattern “is preferred to cloneElement“, which changes the children from the outside. They say you “can clearly trace where the isHighlighted value is coming from”. You see it in the arguments of your function.
Could a hook do this? Yes. The hook is called once, at the top of your component. It keeps the state and gives back a function that answers for each item. Then you write the loop yourself:
import { useState } from 'react'
const products = [
{ id: 1, title: 'Cabbage' },
{ id: 2, title: 'Garlic' },
{ id: 3, title: 'Apple' },
]
function useHighlight(count: number) {
const [selected, setSelected] = useState(0)
return {
isHighlighted: (index: number) => index === selected,
next: () => setSelected((selected + 1) % count),
}
}
export default function App() {
const { isHighlighted, next } = useHighlight(products.length)
return (
<div>
<ul>
{products.map((product, index) => (
<li key={product.id} style={{ fontWeight: isHighlighted(index) ? 'bold' : 'normal' }}>
{isHighlighted(index) ? '> ' : ''}{product.title}
</li>
))}
</ul>
<button onClick={next}>Next</button>
</div>
)
}
It works the same. Real libraries do this too. Downshift’s useSelect hook gives you getItemProps, and its docs call it inside your own items.map(...). TanStack Virtual, a library for long lists like the one in Part 35, gives you a useVirtualizer hook. Its docs say it “does not ship with or render any markup or styles for you”.
So the real question is: who owns the loop, the keys and the tags?
- With the render prop,
Listowns them. Every app that uses it gets the same<ul>, the same keys and the same button. You can’t forget a key. - With the hook, your component owns them. You write more, but you can shape the list any way you like.
The list in Part 35 could go either way. As a component with a renderItem prop, it would do the hard work for any kind of row. Each app would choose what a row looks like. That is the virtual scroller case from React’s old docs.
A component that owns its tags and its state
Some libraries give you ready-made parts, like menus and lists. Some of them still take render props. React Aria is one. Its docs say that className and style “also accept functions which receive states for styling”. A list item can take a function as children, and get isSelected to decide what to show:
<ListBoxItem>
{({isSelected}) => (
<>
{isSelected && <CheckmarkIcon />}
<span>Item</span>
</>
)}
</ListBoxItem>
That is React Aria’s own example. It doesn’t run here, because the playground can only use react and react-dom.
Here, the item owns its tags and its state. You only change one small part of what it shows. A render prop fits that well.
Downshift shows the opposite path. Part 17 met this library. Its Downshift component gives you its state through a function as children. Its main page on GitHub says the component “doesn’t render anything itself”. It “just calls the render function and renders that”. But the same page now tells new users to try its hooks first. It says the component “is going to be removed completely once the hooks become mature”.
So here is a simple guide.
- To share state or logic, write a custom hook.
- Say a component owns the loop or the tags, and you only choose how a piece looks. Then a render prop is a good fit.
Render props and memo
Part 20 showed that memo skips a render only when every prop is the same as before. A function written inside a component is a new function on every render. A render prop is usually written that way. So it breaks memo:
import { memo, useState, type ReactNode } from 'react'
type Item = { id: number; name: string }
const items: Item[] = [
{ id: 1, name: 'Apples' },
{ id: 2, name: 'Bread' },
]
const ItemList = memo(function ItemList({ renderItem }: { renderItem: (item: Item) => ReactNode }) {
console.log('ItemList renders')
return <ul>{items.map(item => <li key={item.id}>{renderItem(item)}</li>)}</ul>
})
export default function App() {
const [clicks, setClicks] = useState(0)
return (
<div>
<button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
<ItemList renderItem={item => <b>{item.name}</b>} />
</div>
)
}
ItemList renders
ItemList renders
ItemList renders
ItemList renders
Run it, then click once. The line ItemList renders shows twice on load and twice more for the click, because of Strict Mode. ItemList is wrapped in memo, and the list didn’t change. But it still rendered.
item => <b>{item.name}</b> is written inside App. So each time App renders, it makes a new function. memo compares the old renderItem with the new one, and they are not the same function. With Strict Mode off, we clicked 3 times, and ItemList rendered 3 more times.
React’s old docs warned about this, for class components. A render prop made during render can cancel the gain from skipping renders. React’s current memo page says it more generally. A value that is “always new” is “enough to break memoization for an entire component”.
Here are two fixes.
- Does the function read nothing from the component? Then move it out, to the top of the file:
function renderName(item: Item) { return <b>{item.name}</b> }. Then passrenderItem={renderName}. It is the same function every time. With Strict Mode off, after 3 clicks,ItemListrendered 0 more times. - If it reads state or props, keep it in the component, and wrap it in
useCallback. Part 21 showed how. List everything it reads in the dependency array. The linter’sexhaustive-depsrule warns when one is missing. The mistakes below show what happens if you leave one out.
Do this only when you have measured that the list is slow. As Part 18 said, most renders are cheap. Part 36 shows how to find what is slow.
The React Compiler, from Part 22, can do this for you. React’s docs show an arrow function written in place and passed as a prop. They say the compiler handles it “with or without the arrow function”. We built this example with babel-plugin-react-compiler 1.0.0, and ran it with Strict Mode off. After 3 clicks, ItemList rendered 0 more times.
Here is how. The compiler moved item => <b>{item.name}</b> out of App, to a function at the top of the file called _temp. That’s the first fix above, done for you. Then it kept the whole <ItemList renderItem={_temp} /> element in its cache, and gave back the same element on every render. As Part 22 showed, React skips a child whose element is the same object as last time. So with the compiler, memo isn’t even needed here. We took memo away, and the compiled app still rendered ItemList 0 more times after 3 clicks.
The playground doesn’t run the compiler, so you won’t see that here.
Common mistakes
Passing JSX where a function is expected
import { useState, type ReactNode } from 'react'
type ToggleState = { on: boolean; toggle: () => void }
function Toggle({ children }: { children: (state: ToggleState) => ReactNode }) {
const [on, setOn] = useState(false)
return children({ on, toggle: () => setOn(!on) })
}
export default function App() {
return (
<Toggle>
<button>The light</button>
</Toggle>
)
}
Toggle wants a function between its tags. Here it gets a <button> element.
An editor that checks TypeScript stops you. It says the element is not assignable to type '(state: ToggleState) => ReactNode'. The playground doesn’t check types, so press Run. React stops with the error children is not a function.
The fix: wrap the JSX in a function, {({ on, toggle }) => <button onClick={toggle}>The light</button>}.
Forgetting to return the JSX
import { useState, type ReactNode } from 'react'
type ToggleState = { on: boolean; toggle: () => void }
function Toggle({ children }: { children: (state: ToggleState) => ReactNode }) {
const [on, setOn] = useState(false)
return children({ on, toggle: () => setOn(!on) })
}
export default function App() {
return (
<div>
<h2>Light switch</h2>
<Toggle>
{({ on, toggle }) => {
<button onClick={toggle}>The light is {on ? 'on' : 'off'}</button>
}}
</Toggle>
</div>
)
}
Run it. The heading shows, but the button doesn’t. And the Console stays empty.
Look at the arrow. It is followed by {, not (. With curly braces, an arrow function needs a return. Without one, it returns undefined, and React shows nothing for undefined, with no warning.
TypeScript doesn’t catch this one. We checked: it found 0 errors. ReactNode includes undefined. And TypeScript lets a function with no return fit a type that may return undefined.
The fix: use ( after the arrow, or write return before the JSX.
Calling the render function in an Effect
Here Toggle calls your function in an Effect, and keeps the result in state. That can look clean. But it shows old data:
import { useEffect, useState, type ReactNode } from 'react'
type ToggleState = { on: boolean; toggle: () => void }
function Toggle({ children }: { children: (state: ToggleState) => ReactNode }) {
const [on, setOn] = useState(false)
const [content, setContent] = useState<ReactNode>(null)
useEffect(() => {
setContent(children({ on, toggle: () => setOn(!on) }))
}, [on])
return content
}
export default function App() {
const [name, setName] = useState('light')
return (
<div>
<button onClick={() => setName('fan')}>Change the name</button>
<Toggle>
{({ on, toggle }) => (
<button onClick={toggle}>The {name} is {on ? 'on' : 'off'}</button>
)}
</Toggle>
</div>
)
}
Run it, and press “Change the name”. The button still says “light”. Now press the light button. Only then does it change, to “The fan is on”.
The Effect runs only when on changes. Pressing “Change the name” gives Toggle a new children function that knows the name “fan”. But on didn’t change, so the Effect didn’t run. The JSX in state is an old copy of what your function returned, made when the name was “light”. It stays on the page until the Effect runs again.
The linter catches this. We ran the Part 10 project’s Oxlint 1.87.0 on this code. It printed two warnings. One starts with React Hook useEffect has a missing dependency: 'children'. The other starts with Calling setState synchronously within an effect can trigger cascading renders.
There’s a second cost: extra renders. We counted with Strict Mode off. On load, Toggle rendered 2 times, and the first render showed nothing, because content was still empty. A click on the light button rendered Toggle 2 times: once for on, and once more for content. “Change the name” rendered it once. The right version renders Toggle once each time.
The fix: call the function while rendering, and return what it gives back. return children({ on, toggle: () => setOn(!on) }). Don’t keep JSX in state.
A useCallback that misses a value
The memo fix above can make the same kind of bug. Here the render function reads bold, but the dependency array is empty:
import { memo, useCallback, useState, type ReactNode } from 'react'
type Item = { id: number; name: string }
const items: Item[] = [
{ id: 1, name: 'Apples' },
{ id: 2, name: 'Bread' },
]
const ItemList = memo(function ItemList({ renderItem }: { renderItem: (item: Item) => ReactNode }) {
return <ul>{items.map(item => <li key={item.id}>{renderItem(item)}</li>)}</ul>
})
export default function App() {
const [bold, setBold] = useState(false)
const renderItem = useCallback(
(item: Item) => (bold ? <b>{item.name}</b> : <span>{item.name}</span>),
[],
)
return (
<div>
<button onClick={() => setBold(!bold)}>Bold: {bold ? 'yes' : 'no'}</button>
<ItemList renderItem={renderItem} />
</div>
)
}
Run it, and press the button. It says “Bold: yes”. But the names are not bold.
useCallback keeps the first function forever, because nothing is in the array. A function keeps the variables of the render that made it. A function and those variables together are called a closure. This one came from the first render, where bold was false. An old closure like this is called a stale closure. Part 11 showed the same problem with an Effect. So memo sees the same renderItem, and skips ItemList.
The fix: [bold]. Then a new function is made only when bold changes. We checked: with [bold], the click made both names bold.
The linter catches this one too. Oxlint’s exhaustive-deps rule printed: React Hook useCallback has a missing dependency: 'bold'.
Calling a hook inside the render function
Your render function looks like a small component. So you may want to give it its own state:
import { useState, type ReactNode } from 'react'
function List({ items, renderItem }: { items: string[]; renderItem: (item: string) => ReactNode }) {
return <ul>{items.map(item => <li key={item}>{renderItem(item)}</li>)}</ul>
}
export default function App() {
const [items, setItems] = useState(['Apples'])
return (
<div>
<button onClick={() => setItems([...items, 'Bread'])}>Add</button>
<List
items={items}
renderItem={item => {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{item}: {count}</button>
}}
/>
</div>
)
}
Run it. Click “Apples”, and it counts. It seems to work. Now press “Add”.
React stops with the error Rendered more hooks than during the previous render. The Console also shows a warning that starts with React has detected a change in the order of Hooks called by List.
Your function runs while List renders. So its useState belongs to List, not to App, and not to the row. List calls your function once per item. One more item means one more useState call in List. That breaks the rule from Part 4: the same hooks, in the same order, on every render. React’s Rules of Hooks say: “Don’t call Hooks inside loops, conditions, nested functions”. Your function is a nested function.
The fix: put the state in a real component, and render that component from your function.
import { useState, type ReactNode } from 'react'
function List({ items, renderItem }: { items: string[]; renderItem: (item: string) => ReactNode }) {
return <ul>{items.map(item => <li key={item}>{renderItem(item)}</li>)}</ul>
}
function Row({ item }: { item: string }) {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{item}: {count}</button>
}
export default function App() {
const [items, setItems] = useState(['Apples'])
return (
<div>
<button onClick={() => setItems([...items, 'Bread'])}>Add</button>
<List items={items} renderItem={item => <Row item={item} />} />
</div>
)
}
Now each Row has its own state. Click “Apples”, then “Add”. “Apples: 1” keeps its count, and “Bread: 0” appears next to it.
The linter catches the first version before you run it. Oxlint’s rules-of-hooks rule printed an error that starts with React Hook "useState" cannot be called inside a callback. On the fixed version, it printed nothing.
Nesting render prop after render prop
You saw this in WithRenderProps. Three render props, used together, put your JSX three functions deep. It is hard to read, and hard to change. If you only need the data, use hooks. Keep render props for the cases above.
Practice
Press Edit on the examples above and try these.
- In “Try this first”, change the render function so it logs first:
render={({ on, toggle }) => { console.log('your function runs'); return <button onClick={toggle}>The light is {on ? 'on' : 'off'}</button> }}. How many lines does the Console show on load? How many more after one click? - In the
memoexample, move the render function out ofApp, as the fix says. Click 3 times. How manyItemList renderslines are there in all? - Write a
Countercomponent with a render prop calledchildren. It gets{ count, increase }. Use it twice inApp. Once as a button that shows the count. And once as a paragraph with its own “Add one” button. - Write the code of
Counterfrom task 3 again, as auseCounterhook. Then makeCountercall the hook, the wayToggledid in “The same three, as hooks”.
Answers
- On load, 6 lines. First “App renders” twice. Then “Toggle renders, on is false” and “your function runs”, and those two again. Strict Mode runs
Toggletwice, and each run calls your function. One click adds 4 more lines: “Toggle renders, on is true”, “your function runs”, and those two again. “App renders” doesn’t come back. - Two lines in all, both from the first render. Strict Mode runs
ItemListtwice on load. After that,renderItemis the same function every time, somemoskipsItemListon each click. The full app is below. - Each
Counterkeeps its own count. Two clicks on the first and one on the second give “Count: 2” and “Apples in the basket: 1”. The full app is below. - Both ways use the same hook, and each keeps its own count. The full app is below.
Answer 2, the render function moved out of App:
import { memo, useState, type ReactNode } from 'react'
type Item = { id: number; name: string }
const items: Item[] = [
{ id: 1, name: 'Apples' },
{ id: 2, name: 'Bread' },
]
const ItemList = memo(function ItemList({ renderItem }: { renderItem: (item: Item) => ReactNode }) {
console.log('ItemList renders')
return <ul>{items.map(item => <li key={item.id}>{renderItem(item)}</li>)}</ul>
})
function renderName(item: Item) {
return <b>{item.name}</b>
}
export default function App() {
const [clicks, setClicks] = useState(0)
return (
<div>
<button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
<ItemList renderItem={renderName} />
</div>
)
}
ItemList renders
ItemList renders
Answer 3, a Counter with a function as children:
import { useState, type ReactNode } from 'react'
type CounterState = { count: number; increase: () => void }
function Counter({ children }: { children: (state: CounterState) => ReactNode }) {
const [count, setCount] = useState(0)
return children({ count, increase: () => setCount(c => c + 1) })
}
export default function App() {
return (
<div>
<Counter>
{({ count, increase }) => <button onClick={increase}>Count: {count}</button>}
</Counter>
<Counter>
{({ count, increase }) => (
<p>
Apples in the basket: {count} <button onClick={increase}>Add one</button>
</p>
)}
</Counter>
</div>
)
}
Answer 4, useCounter, and a Counter that calls it:
import { useState, type ReactNode } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
return { count, increase: () => setCount(c => c + 1) }
}
function Counter({ children }: { children: (state: ReturnType<typeof useCounter>) => ReactNode }) {
const state = useCounter()
return children(state)
}
export default function App() {
const { count, increase } = useCounter()
return (
<div>
<button onClick={increase}>Hook: {count}</button>
<Counter>
{state => <button onClick={state.increase}>Render prop: {state.count}</button>}
</Counter>
</div>
)
}
Interview questions
Try to answer each one out loud before you open the answer.
What is a render prop?
A prop whose value is a function that returns what to show. The component keeps some state or data. It calls the function while it renders, and passes that data in. The function decides what to show. React’s old docs call it “a function prop that a component uses to know what to render”. To React it is a normal prop. Only the component that calls it treats it in a special way.
A strong answer gives an example, like <MouseTracker render={point => <Dot point={point} />} />. It also says the function runs during render, so it must be pure.
What is the difference between a render prop and children as a function?
Only the name of the prop. <Toggle>{fn}</Toggle> passes fn as children. <Toggle render={fn} /> passes it as render. The component calls children(data) or render(data). In TypeScript, both props have a function type, like (state: ToggleState) => ReactNode.
A strong answer says when to pick which. children reads well when one function fills the whole component. A named prop like renderItem says what it is for, and a component can take several.
Why were render props popular, and what replaced them?
Before React 16.8, in 2019, a component with state had to be a class. Classes have no hooks. Render props and higher-order components were the main ways to share code that used state. Custom hooks then did the same job with less code. React’s old docs now say render props “have been replaced by custom Hooks” in many cases.
A strong answer names the costs of render props. They add a wrapper component for each shared piece. Using several puts one inside another, deeper and deeper. React’s docs called that “wrapper hell”. A hook is one line, and adds no component.
When would you still use a render prop today?
When the component owns a loop or its tags, and the caller only chooses how a piece looks. A list with a renderItem(item, isHighlighted) prop is the common case. The list owns the items, the keys and the highlight. It calls your function for each item. A hook can do the same job, like Downshift’s useSelect with getItemProps. But then the caller writes the loop, the keys and the tags. The real choice is who should own them. React’s docs show this pattern as better than changing children with cloneElement. React’s old docs name “a virtual scroller component” with a renderItem prop.
A strong answer names a library. React Aria lets className, style and children be functions that get the item’s state, like isSelected.
Why can a render prop make React.memo useless?
The function is usually written inside the parent. So it is a new function every time the parent renders. memo compares props with Object.is, and a new function is never equal to the old one. So the memoized child renders every time. Fixes: move the function out of the component if it reads nothing from it. Or wrap it in useCallback, with every value it reads in the dependency array. The React Compiler can also keep the function the same for you.
A strong answer warns about the second fix. With a missing dependency, memo skips the child, and it shows old data.
How do render props, higher-order components and hooks differ?
All three share logic between components. A render prop is a component that calls your function to decide what to show. A higher-order component is a function that takes a component. It returns a new one that adds behaviour, and sometimes props. Part 41 covers it. A hook is a function you call inside your component, and it gives back values. Render props and higher-order components add components to the tree. Hooks don’t.
A strong answer adds the history. Render props and higher-order components came first. Hooks are the newest of the three, added in React 16.8, and they are the default today for sharing logic. Render props stay useful when a component owns the markup or the loop.
Can you call a hook inside a render prop function?
No. It breaks the Rules of Hooks: the hook is in a nested function. It runs during the render of the component that calls the function, so the hook belongs to that component. It may seem to work until the number of calls changes. In our test, a list called the function once per item. When the list got one more item, React stopped with Rendered more hooks than during the previous render. The fix: put the hook in a real component. Then render that component from the function: {item => <Row item={item} />}.
A strong candidate names the linter rule that catches it, rules-of-hooks. Oxlint’s version printed an error that starts with React Hook "useState" cannot be called inside a callback.
Sources
- Render Props, React’s old docs: the definition, the mouse example,
childrenas a function, the libraries that used it, the warning about functions made in render, and “replaced by custom Hooks”. - Hooks at a Glance: Motivation and Hooks FAQ, React’s old docs: “wrapper hell”, and “There is still a place for both patterns”, with the
renderItemand virtual scroller example. - React v16.8: The One With Hooks, React blog, February 2019: when hooks arrived.
- cloneElement and Children, react.dev:
renderItemas an alternative, “a regular prop which happens to be a function”, and why you can trace where the data comes from. - Reusing Logic with Custom Hooks, react.dev: custom hooks share logic.
- Rules of Hooks, react.dev: no hooks inside loops, conditions or nested functions.
- memo and useCallback, react.dev: a value that is always new breaks memoization, and how to keep a function.
- React Compiler, react.dev: it keeps a function prop “with or without the arrow function”.
- Common components, react.dev:
onPointerMove. - MDN: pointermove, MouseEvent.clientX and touch-action.
- Downshift README, GitHub: the
Downshiftcomponent’s children function, and the advice to use its hooks instead. - Styling, React Aria docs: render props for
className,styleandchildren, and theListBoxItemexample. - The console output, render counts, page text, errors and the compiler test come from running React 19.3.0, TypeScript 7.0.2 and
babel-plugin-react-compiler1.0.0 for this post. - This part follows the Render Props kata in react-katas.