🧠 Big Brain React: Preventing expensive remounts by... preventing unmounts
One minute (re)mounts
So here was the situation: at work, we had a component that was incredibly expensive to mount – in some cases, mounting literally took a whole minute. This could have been tolerable if it happened rarely, e.g. perhaps when the page first loads, but unfortunately we were remounting this component whenever the user toggled between some tabs we had in the app.
Typically, we would want to solve this by reducing the amount of work done. Unfortunately, this component couldn't be made any simpler as its requirements were inherently complex, and it was so heavy because it was rendering thousands of React elements, resulting in tens of thousands of DOM elements.
React's official guidance on optimizing performance recommends a potential solution for us: we could reduce the number of DOM elements rendered simply by not rendering stuff that will be offscreen. However, virtualization solutions require you to know the heights of your components beforehand. Because our virtualizable components have different sizes, our virtualization solution relies on the initial layout of all DOM elements to know their positions. While we could manually calculate this, the engineering cost of implementing that would be too high.
If we jump into the future, we may also be able to use concurrent React's time-slicing renders to optimize our UX: instead of freezing the app while this component mounts, we could perform this mount in a transition and allow the app to still be usable while this expensive component rendered. Unfortunately, a huge chunk (>1s) of the freezes is caused by both committing those tens of thousands of DOM elements to the DOM and the subsequent browser work required to calculate the styles and layouts of these elements, both of which are not improved by concurrent mode. In short, concurrent mode will help but will not solve this completely.
Solution
So, how can we solve this? Simple – prevent remounts by keeping the component tree mounted *taps head*
Granted, this won't actually improve that initial mount, but at least we can isolate that to the initial page load. Plus, we don't even have to do the work of lifting component ownership to a higher component in the tree (the more "correct" way of doing this) – it turns out that there is an incredibly easy way to do this without drastically changing your existing component tree.
Before we go any further, behold, a demo of our solution:
In order to feasibly keep a (very) heavy component tree mounted, we need to do 2 things:
- Prevent a component tree from showing up on screen or otherwise affect the page layout.
- Prevent it from performing expensive renders while offscreen, so that this solution doesn't regress perf.
Hiding a subtree offscreen
This is the simpler problem of the two, but there are some interesting things that we'd want to keep in mind.
There are a few ways to make a DOM element, in technical terms, go away:
display: none
. If you're anything like me, this is the most obvious thing you'd reach for, and voila – the element disappears from view. Problem solved, right? Can we go home and not think about perf anymore? Not quite. Turns out,display: none
is removes this element from the render tree. As a consequence, removing thedisplay: none
rule when you want to restore the element causes the browser to perform potentially expensive style and layout calculations. In our app, these style + layout calculations took around 2 seconds to process the tree of >32000 DOM elements. While obviously a huge improvement over 1 minute, it's still not quite fast enough to feel snappy.visibility: hidden
. While this rule sounds basically identical todisplay: none
(and in fact I used to confuse the 2 all the time), they differ in 2 very important ways. First,visibility: hidden
does not remove the element from the render tree. This means that when we remove the rule, the browser has much less work to do – in our app, around 100-400ms. Unfortunately,visibility: hidden
also keeps the element in the page layout and simply blanks it out. In our app, this would mess with the layout of our other tabs. How can we fix this? Enter:position: absolute
. Coupled with nice big negative coordinates, we can banish this element far offscreen without incurring much style + layout calculations when we want to bring it back. Unfortunately, this doesn't prevent the element's contents from showing up in the browser's page search results. Thankfully,visibility: hidden
fixes that.
Resulting <HideOffscreen>
component:
function HideOffscreen({ children, hide }) {
return (
<div
style={
hide
? {
// Move contents out of the page's layout and far offscreen
position: "absolute",
top: "-9999px",
left: "-9999px",
// Prevent contents from showing up in browser's Find feature
visibility: "hidden"
}
: undefined
}
>
{children}
</div>
);
}
Preventing a subtree from updating
Although we have found a way to shove this component offscreen, it'll still be expensive to render if it's allowed to. Enter our <Freeze>
component:
const Freeze = memo(
({ children }) => <>{children}</>,
(_prevProps, nextProps) => nextProps.freeze
);
Really concise! But in verbose English: once freeze
is true, <Freeze>
will prevent children
from rendering. In fact, you can pass in any children
and it won't render. Mindblowingly, this also means that you don't even need to pass in any children
. This hypothetical test will pass:
import { render, screen } from '@testing-library/react';
test('should not unmount children when freeze flips from false->true and children is no longer provided', () => {
const { rerender } = render(<Freeze freeze={false}>child</Freeze>);
expect(screen.getByText('child')).toBeInTheDocument();
rerender(<Freeze freeze />);
expect(screen.getByText('child')).toBeInTheDocument();
});
What this means in practice: if, like us, your expensive component requires props that are difficult to compute when we switch to another tab, we can simply use this <Freeze>
component and not pass in a React element.
Everybody now!
Here's what our resulting code looks like in production:
function Component({ showExpensiveComponent }) {
const propsThatOnlyExistIfShowExpensiveComponentIsTrue =
showExpensiveComponent && computeThatSomehow();
return (
<HideOffscreen hide={!showExpensiveComponent}>
<Freeze freeze={!showExpensiveComponent}>
{showExpensiveComponent && <ExpensiveMount {...propsThatOnlyExistIfShowExpensiveComponentIsTrue} />}
</Freeze>
</HideOffscreen>
);
}
When showExpensiveComponent
flips from true to false, <ExpensiveMount>
remains mounted but shoved offscreen, and when it flips back to true <ExpensiveMount>
is simply moved back onscreen and resumes rendering. Cool huh?
Drawbacks
Unfortunately, <Freeze>
is rather incomplete. Because it uses React.memo
, it inherits all its limitations. In particular, it only prevents children
from rendering when its parent renders. It cannot prevent children from rendering if:
- A component in the subtree consumes React contexts/Redux/Apollo/other global data that updated. State updates can be scheduled on components within the subtree and
<Freeze>
cannot stop that from happening. - A component in the subtree unsuspended.
So, the existence of this trick does NOT mean you can start shoving components offscreen without being careful to prevent them from coming back to bite your perf. Please use this solution carefully and only as a last resort!