React

How Props and Children Render the Same Pixels Differently

AminAmin
April 21, 2026
Featured
How Props and Children Render the Same Pixels Differently

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.

  • /props uses the first generic pattern ListLayout<T> that takes items, renderItem and getKey.
  • /children uses the second. a ListLayout that just wraps {children} in a div.
fig: path to output for both ways
fig: path to output for both ways

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.

fig: where the cards being generated
fig: where the cards 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 objects137
fibers React diffs137
function calls2715
DOM nodes committed4337
closures created by the parent2 (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.

fig: resulting in a 16% increase in DOM nodes for a 6-item list
fig: resulting in a 16% increase in DOM nodes for a 6-item list

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.

fig: heap objects per render
fig: heap objects per render

TS & Types

The props version is generic here ListLayout<T>. we write <ListLayout<User> …> and the types flow is

  • items must be User[]
  • the parameter of renderItem is inferred as User
  • the parameter of getKey is inferred as User

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

fig: typo checking in both ways
fig: typo checking in both ways

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.

TagsReactPerformanceTypeScript
How Props and Children Render the Same Pixels Differently