Kevin Farrugia
Posted on August 3, 2020
Cross-posted from https://imkev.dev/react-rendering-order
I was recently asked to measure & track the performance of a React component (and all its sub-components) as part of a huge refactoring project the company had undertaken. In a nutshell, we wanted to track how long the component takes until its rendering is complete. Since the component was made up of a number of smaller sub-components, many of these connected to the Redux store and fetching data asynchronously, it was important to understand how the React rendering algorithm works. I have already written about my learning experience when measuring the time each component takes to render which goes into greater detail about the algorithm itself; while this blog post takes a very simplified and high-level overview of the order in which components are rendered and re-rendered using examples.
Demo
To demonstrate the order in which React components are rendered, we have created a simple component tree and tagged each component with a unique ID.
<Component id="A0">
<Component id="B0" />
<Component id="B1">
<Component id="C0" />
<Component id="C1" />
</Component>
<Component id="B2" />
</Component>
By adding a React.Profiler
component to each Component
we are able to measure when each component renders. The sequence for the above component tree is
- B0
- C0
- C1
- B1
- B2
- A0
This is because the React reconciliation algorithm follows a depth-first traversal to beginWork
and a component's rendering is complete (completeWork
) only once all its children's rendering is complete. As a result, the root component in your tree will always be the last one to complete render.
You may experiment with the source code if you wish.
But what about connected components & async rendering?
Very often (as was our case) components and sub-components are connected to Redux store or asynchronously fetching data from an API. In some cases, we are also using the render prop technique, in which case data is fetched by a parent component and then passed down to its children. In these cases, how does the React reconciliation algorithm behave?
<Component id="A0">
<Component id="B0" />
<Component id="B1">
<Component id="C0" />
<Component id="C1" />
<RenderProps id="C2" timeout={2000}>
{prefix => (
<>
{prefix && (
<Component id={`${prefix}D0`}>
<Component id={`${prefix}E0`} />
<Component id={`${prefix}E1`} />
</Component>
)}
<Component id={`${prefix}D1`} />
</>
)}
</RenderProps>
<Container id="C3" timeout={1000}>
<Component id="D2" />
</Container>
</Component>
<Component id="B2" />
</Component>
In the above example, Container
simulates a component which fetches data asynchronously, while RenderProps
simulates a component which fetches data asynchronously and then passes this to its children as a prop (prefix
); some of which render conditionally based on its value (initially false). In both cases, the timeout
prop is used to define how long the asynchronous event would take until the data is "fetched" and it is only there for demonstration purposes as it has no impact on our test.
Similarly to the previous example, we are able to determine when each component finishes rendering thorugh the use of React.Profiler
. Initially, the components will render based on the same rules as above, depth-first traversal and all children must complete render.
- B0
- C0
- C1
- D1
- C2
- D2
- C3
- B1
- B2
- A0
After 1000ms, the component C3 should resolve its asynchronous event as its data is fetched. As a result, it is re-rendered, together with its parents nodes until A0. Therefore, the order of this re-render is:
- C3
- B1
- A0
Note that only the parents of C3 are rendered, while its siblings and children do not re-render.
Another 1000ms later and component C2 now resolves. Similarly to C3, its data is fetch and re-rendered. Additionally, it will also pass the render prop prefix
to its children and the conditional render is now truthy. The resultant render complete order is as follows:
- E0
- E1
- D0
- D1
- C2
- B1
- A0
As can be seen, when using render props, in addition to having the parent components render, all children are re-rendered - with same rules as the each render, depth-first traversal and all children need to complete for the parent to complete.
You may experiment with the source code for the above example too.
So which is the last render?
Using the above information, we were able to confidently say that the entire component tree is ready from rendering when our root node (A0 in the example above) has rendered for the last time. Unless within a finite amount of time, measuring the "last" of anything is difficult as on each iteration you do not know if there will be a successive one. To solve this, we looked and imitated how Largest Contentful Paint works, as it has a similar challenge (how do you know an element is the largest if you don't know what's coming next?). Ultimately, the solution was relatively straightforward as we created a performance.mark
for each render of our root component. The last mark is the last render and each previous mark was the last render until that point.
window.addEventListener("unload", () => {
// get the last performance.mark entry
const data = performance.getEntriesByName("lastRender")[performance.getEntriesByName("lastRender").length - 1];
// Use `navigator.sendBeacon()` if available, falling back to `fetch()`.
(navigator.sendBeacon && navigator.sendBeacon('/analytics', data)) ||
fetch('/analytics', {data, method: 'POST', keepalive: true});
});
The final piece of the puzzle was to send this data to the performance monitoring tool we were using. In our case it is SpeedCurve, which provides an API; but the same approach used by SpeedCurve works for Google Analytics or other RUM tools. Using the non-blocking sendBeacon() API on unload
and on history change (if your app is a SPA); you could POST the timings of the last performance.mark
to an endpoint.
And that's a wrap 🌯. Thank you for reading and shout out to @maxkoretskyi for his fantastic articles on the topic.
Posted on August 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.