This article is a part of the series "React performance"
The nuance of React rendering behaviour as it relates to children
All code examples can be found here:
In a previous post I highlighted the difference between two subtly different components - one that receives its content via a children
prop, and the other that renders its content directly.
Lines 8 to 32 in b91494b
8export function ChildrenStyleOne() {
9 const [value, setValue] = React.useState(0)
10 return <div className="some-parent-component">
11 <strong>ChildrenStyleOne</strong>
12 <p>RenderTracker is directly rendered</p>
13 <button onClick={() => {
14 setValue((prev) => prev + 1);;
15 }}>Increase count: {value}</button>
16 {/* ð Here we declare the RenderTracker directly in the component */}
17 <RenderTracker />
18 </div >
19}
20
21export function ChildrenStyleTwo(props: React.PropsWithChildren) {
22 const [value, setValue] = React.useState(0)
23 return <div className="some-parent-component">
24 <strong>ChildrenStyleTwo</strong>
25 <p>RenderTracker is rendered as props.children</p>
26 <button onClick={() => {
27 setValue((prev) => prev + 1);;
28 }}>Increase count: {value}</button>
29 {/* ð Here, it is passed from the parent via the `children` prop */}
30 {props.children}
31 </div >
32}
☝️ Interactive demo
RenderTracker is directly rendered
RenderTracker is rendered as props.children
Observe that the RenderTracker that is directly declared by the component rerenders on each state change.
Observe that the RenderTracker that is passed in via props.children does not.
At the time, I described this as an "ambiguity about what we call 'children' in React".
However, if take a look at React's own Understanding Your UI as a tree documentation, we can see that they make no such distinction between children that are provided via the props.children
prop and children that are directly rendered.
In their example they have this jsx:
<>
<FancyText title text="Get Inspired App" />
<InspirationGenerator>
<Copyright year={2004} />
</InspirationGenerator>
</>
and this corresponding render tree diagram:

They make no distinction between FancyText
and Copyright
even though one is directly rendered and the other is provided by props.children
.
See my issue querying why the difference in rendering behaviour is not mentioned here:
This kind of lines up with how I've conceptualised of what React is doing, where if I write something like this:
function Foo(){
return <Bar text="hello world!"/>
}
it would be like me writing
function Foo(){
return Bar({text: "hello world!"})
}
this isn't quite accurate.
Let's look at some actual compiled JSX:
export function ChildrenStyleOne() {
return <div id="foo">
<p>A regular node</p>
<SomeThing text="world!" />
</div>
}
compiles to:
function ChildrenStyleOne() {
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
id: "foo",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
children: "A regular node",
}),
// Note `SomeThing` remains uncalled. 👇
/* @__PURE__ */ jsxRuntimeExports.jsx(SomeThing, { text: "world!" }),
],
});
}
That is, the compiled function does not call SomeThing
; it merely retains a reference to it and what its props will be.
export function ChildrenStyleTwo(props: React.PropsWithChildren) {
return <div id="bar">
<p>A regular node</p>
{props.children}
</div>
}
compiles to:
function ChildrenStyleTwo(props) {
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", {
id: "bar",
children: [
/* @__PURE__ */ jsxRuntimeExports.jsx("p", {
children: "A regular node",
}),
// 👇 a straight reference to props.children
props.children,
],
});
}
This makes sense, rather than React resolving the entire UI tree a single event loop, it makes sense that the React engine 'takes a breath' between each UI node, and waits till the next event loop to resolve the next nodes. This way - resolving the UI tree is not one big thread blocking execution.
At this point we've just got some compiled code - let's actually execute the code:
function log(obj) {
console.log(
util.inspect(obj, {
showHidden: false,
depth: null,
colors: true,
})
);
}
log(ChildrenStyleOne({}));
logs:
{
'$$typeof': Symbol(react.transitional.element),
type: 'div',
key: null,
ref: null,
props: {
id: 'foo',
children: [
{
'$$typeof': Symbol(react.transitional.element),
type: 'p',
key: null,
ref: null,
props: { children: 'A regular node' }
},
// 👇 this is the node we are interested in
{
'$$typeof': Symbol(react.transitional.element),
type: [Function: SomeThing],
key: null,
ref: null,
props: { text: 'world!' }
}
]
}
}
We can see here we still haven't called SomeThing
- we're still just maintaining a reference to the function and the props that will be passed to it.
If we log the props.children
style function call:
log(
ChildrenStyleTwo({
children: jsxRuntimeExports.jsx(SomeThing, {}),
})
);
We get:
{
'$$typeof': Symbol(react.transitional.element),
type: 'div',
key: null,
ref: null,
props: {
id: 'foo',
children: [
{
'$$typeof': Symbol(react.transitional.element),
type: 'p',
key: null,
ref: null,
props: { children: 'A regular node' }
},
// 👇 this is the node we are interested in
{
'$$typeof': Symbol(react.transitional.element),
type: [Function: SomeThing],
key: null,
ref: null,
props: { text: 'world!' }
}
]
}
}
We get the exact same looking structure.
What gives? Where does the difference in rendering behaviour come from?
Well, observe what happens if we call this code:
const childrenStyleOneProps = {};
const a = ChildrenStyleOne(childrenStyleOneProps);
const b = ChildrenStyleOne(childrenStyleOneProps);
console.log(a.props.children[1] === b.props.children[1]);
That is - we're asking, is this object:
{
'$$typeof': Symbol(react.transitional.element),
type: [Function: SomeThing],
key: null,
ref: null,
props: { text: 'world!' }
}
the same the first time we call it, vs the second time we call it?
And the answer is:
false
Whereas, in this case;
const childrenStyleTwoProps = {
children: jsxRuntimeExports.jsx(SomeThing, {}),
};
const c = ChildrenStyleTwo(childrenStyleTwoProps);
const d = ChildrenStyleTwo(childrenStyleTwoProps);
console.log(c.props.children[1] === d.props.children[1]);
the answer is
true
and this is where the difference in rendering behaviour lays.
Essentially, what's happening as I understand it - is that when a React component renders, it generates one of these tree node objects, and then steps over each object inside, and compares it with the one from the previous render.
If they are shallowly equal (i.e. ===
equal), then React does not need to bother stepping into that node in order to resolve its subtree.
With React 19/Compiler this might not matter anyway
The state of Compiler is it's an opt feature that is ready to use.
Compiler will automatically memoise properties and components.
I've updated my vite.config.js to allow incremental adoption of compiler:
Lines 6 to 13 in b91494b
6 plugins: [react({
7 babel: {
8 plugins: [['babel-plugin-react-compiler',
9 {compilationMode: 'annotation'},
10
11 ]],
12 },
13 })],
And I add the "use memo"
annotation to my component:
Lines 8 to 20 in b91494b
8export function ChildrenStyleOne() {
9 "use memo"
10 const [value, setValue] = React.useState(0)
11 return <div className="some-parent-component">
12 <strong>ChildrenStyleOne</strong>
13 <p>RenderTracker is directly rendered</p>
14 <button onClick={() => {
15 setValue((prev) => prev + 1);;
16 }}>Increase count: {value}</button>
17 {/* ð Here we declare the RenderTracker directly in the component */}
18 <RenderTracker />
19 </div >
20}
and now we see that no rerenders occur in either case:
☝️ Interactive demo
RenderTracker is directly rendered
RenderTracker is rendered as props.children
Observe that both Render trackers do not rerender on state changes
Questions? Comments? Criticisms? Get in the comments! 👇
Spotted an error? Edit this page with Github