Black Sheep Code

The nuance of React rendering behaviour as it relates to children

Published:
info

All code examples can be found here:

https://github.com/dwjohnston/react-renders

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.

☝️ Interactive demo

ChildrenStyleOne

RenderTracker is directly rendered

Render Tracker

ChildrenStyleTwo

RenderTracker is rendered as props.children

Render Tracker

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:

A tree diagram showing the InspirationGenerator node having two children - FancyText and Copyright

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:

And I add the "use memo" annotation to my component:

and now we see that no rerenders occur in either case:

☝️ Interactive demo

ChildrenStyleOne

RenderTracker is directly rendered

Render Tracker

ChildrenStyleTwo

RenderTracker is rendered as props.children

Render Tracker

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