Do you pass data and a render function?
<ListLayout items={users} renderItem={(u) => <UserCard user={u} />} />
Or do you pass JSX as children and let the caller do the mapping?
<ListLayout>
{users.map((u) => (
<UserCard key={u.id} user={u} />
))}
</ListLayout>
Both render the same pixels at build. both feels RIGHT. So which one do you actually pick?
This post is the comparison of that two methods.
before you read this, check the code on GitHub and see the live preview at propsvschildren.maamspy.com first.
How the compiler sees them
I made a tiny Next.js repo with two pages that render the exact same list of 6 users. Same styles, same UserCard, same data file.
/propsuses the first generic patternListLayout<T>that takesitems,renderItemandgetKey./childrenuses the second. aListLayoutthat just wraps{children}in a div.

None of these versions survive JSX. When the code hits the browser, SWC has rewritten everything into _jsx(...) calls.
- In the props version, the compiled output looks roughly like this
_jsx(ListLayout, {
items: users,
getKey: (u) => u.id,
renderItem: (u) => _jsx(UserCard, { user: u }),
});
right now, zero card objects exist. and they will only exist later, when ListLayout calls renderItem from inside its own .map.
- In the children version, the same spot compiles to
_jsx(ListLayout, {
children: users.map((user) => _jsx(UserCard, { user }, user.id)),
});
When ListLayout is called, all six UserCard element objects already exists. they are in an array inside props.children. ListLayout is just going to forward them.
so here i tried to show you where the UserCards are being generated.

Counts
For 6 users, here is what actually gets allocated on one render. (i have measured them via manual instrumentation & React DevTools Profiler extension. and there is some inferred counts)
| /props | /children | |
|---|---|---|
| element objects | 13 | 7 |
| fibers React diffs | 13 | 7 |
| function calls | 27 | 15 |
| DOM nodes committed | 43 | 37 |
| closures created by the parent | 2 (escape to heap) | 0 escaping |
Because of the line below, inside the ListLayout of the props version , it is doubling things basically!
{
items.map((item, i) => (
<div key={getKey(item, i)}>{renderItem(item, i)}</div>
));
}
That wrapper <div> is where the extra 6 elements, 6 fibers and 6 DOM nodes come from. it needs to be there as the ListLayout owns the key without knowing the identity, so the safest thing is to attach the key to a div it controls.
In the children version, the parent attaches key={user.id} directly to the UserCard and that extra div just does not need to exist.
Actually the props version is a design mistake in this case.

V8
Once React has its element trees, everything else is just JS running in V8 engine. i have two things to talk about here.
Closures. Every time the parent page renders in the props version, V8 has to allocate two fresh arrow functions. one for getKey and another for renderItem. both are passed down as props, which means they escape their frames. They go on the heap.
The children version allocates one arrow for .map, but it never escapes. that invokes inline and thrown away. Modern V8 is usually smart enough to stack allocate that.
Inline caches. Both versions keep call sites monomorphic. Turbofan will inline those arrows into ListLayout map. So at steady state, the function call count I quoted above, translates to a slightly bigger optimized frame.
For 6 users, none of this matters. You will not measure it. for millions of users in a virtualized list, with a parent rendering in high frame rate because of some unrelated state change, you will notice.

TS & Types
The props version is generic here ListLayout<T>. we write <ListLayout<User> …> and the types flow is
itemsmust beUser[]- the parameter of
renderItemis inferred asUser - the parameter of
getKeyis inferred asUser
If someone inside the callback writes user.emial instead of user.email, TypeScript must catch it there.
The children version types children as React.ReactNode. Which is basically anything renderable here. the caller catches mistakes inside its .map body. ListLayout itself is blind here

which do I reach for
Children is the norm for containers. Every design system you have touched like Chakra, MUI, shadcn, their <Stack>, <Flex>, <Grid>, <Card>, <Modal>, <Drawer>, <Popover>, <Accordion> are all children shaped. They do not care what is inside.
The props pattern is much narrower. You see it in TanStack Table, old React Router <Route render={...}> or in Recharts.
I reach for children when the component is a dumb container and has no opinion about will be inside. Cards, panels, modal bodies, sidebars, accordions. something like a Layout component.
I reach for props when the component is a data primitive. tables, virtualized lists or grids. Anything that needs to own the iteration.
Bye
If you are writing React for a while you already know both patterns. but this is surprising, how different the machine is underneath. 2x the element objects. 2x the fibers. 6 extra DOM nodes. 2 heap escaping closures per render.
Pick the pattern that matches the contract. Measure before you optimize. That is it.

