Optimizing React: Part 2 - Understanding Memoization
Table of Contents
This is the second article in a series covering techniques for optimizing React's performance by minimizing renders.
- Part 1 - Understanding Renders
- Part 2 - Understanding Memoization (this post)
- Part 3 - Avoiding Memoization
In the previous article, we covered why a component can re-render, and how variables are given new references on each render.
Now that we have a better understanding of the basics, we can begin to cover how we can optimize a React app. However in order to do so, we need to actually work on an example application. The example I'm about to introduce is incredibly silly and quite long, but we need it to be a few components deep so we could systematically walk through how it could be optimized.
A Random Number List Generator and Counter Example
This is what our example app looks like:
And it does the following:
- Displays a list of random numbers.
- Each random number is styled either with a black or white background. How this is decided is by generating another random number, and checking if it's even or odd.
- We also give the user a button to click, which increments a counter.
Below is the code for the above app (I've stripped out any CSS, for simplicity):
ReactDOM.render(<App />, rootElement); // The Parent component - note that it has no state // and passes a fixed prop to RandomNumberListAndCounter function App() { return ( <div className="App"> <RandomNumberListAndCounter length={10} /> </div> ); } function RandomNumberListAndCounter({ length }) { const [counter, setCounter] = useState(0); // We keep the random number list in state, so that //we can control when the list of random numbers is //updated, independent of re-renders of the component. const [randomNumbers, setRandomNumbers] = useState( generateListOfRandomNumbers(length) ); // Generate a different set of random numbers every time length changes useEffect(() => { setRandomNumbers(generateListOfRandomNumbers(length)); }, [length]); const incrementOnClick = () => { setCounter((prev) => prev + 1); }; // An object that is the prop to ButtonWithFooter // A little unusual, but sometimes you may need to pass // complex objects to a component const configProp = { name: "Special counter incrementer", listLength: length, }; return ( <div> {/* Displays the counter */} <FancyHeader counter={counter} /> {/* Displays the styled list of random numbers */} <FancyNumberListFormatter numberList={randomNumbers} /> {/* Increments the counter, and shows how many random numbers are shown*/} <ButtonWithFooter config={configProp} onClick={incrementOnClick} /> </div> ); } function FancyHeader({ counter }) { return ( <hgroup> <h1>My Special Counter</h1> <h2>Clicked {counter} times.</h2> </hgroup> ); } function FancyNumberListFormatter({ numberList }) { return ( <div> {numberList.map((number, i) => ( <RandomlyStyledNumber number={number} key={`${number}-${i}`} /> ))} </div> ); } function RandomlyStyledNumber({ number }) { const [isEven] = useState(getExpensiveRandomNumber() % 2 === 0); // I'm skipping showing any styling const evenStyle = {}; const oddStyle = {}; return <span style={isEven ? evenStyle : oddStyle}>{number}</span>; } function ButtonWithFooter({ config, onClick }) { return ( <> <div> <button onClick={onClick}>{config.name}</button> </div> {/* Normally you wouldn't include a footer with a button, but this is only for the purpose of the example */} <footer> <em>Number of random numbers shown: {config.listLength}</em> </footer> </> ); }
The functions to generate the random numbers use the browser crypto
library.
I've written it in a way so that it's really slow.
It is not important to understand how they work, I'm just including it if you're curious:
function getExpensiveRandomNumber() { return new Array(1000) .fill(null) .map( () => Array.from(window.crypto.getRandomValues(new Uint16Array(1000)))[0] )[0]; } function generateListOfRandomNumbers(length) { return new Array(length).fill(null).map(getExpensiveRandomNumber); }
This is the hierarchy of the components:
So how does this app perform? To answer this question, I'm going to use the React DevTools Profiler to measure how long it takes to render all the components:
- I just want the
RandomNumberListAndCounter
to re-render, so I can do this by clicking on the increment counter button. - Before clicking the counter button, I hit record on the profiler, then after I see the counter increment, I stop recording.
- If any renders took place, the profile will spit out a flame graph of how long each component took to render.
A few observations about the profiler:
- We can see the total time it took to render
RandomNumberListAndCounter
and all its children was a whopping 1.25 seconds! App
did not re-render (we can tell because of its gray color).- Even though only the text "Clicked 1 times" (which belongs to
FancyHeader
) is what changed, all the child components ofRandomNumberListAndCounter
re-rendered.
Optimizing the Example
We desperately need to fix the slow re-rendering of the example every time we increment the counter. And we can actually do this without memoization, by using useState
's initialization function.
Currently, every time we render the component, we're generating and throwing away the random number list.
By passing an initialization function to useState
, React will only invoke the function once (on the component's first render).
This is the existing problem code:
// In RandomNumberListAndCounter: const [randomNumbers, setRandomNumbers] = useState( generateListOfRandomNumbers(length) ); // In RandomStyledNumber: const [isEven] = useState(getExpensiveRandomNumber() % 2 === 0);
And this is the fixed code:
// In RandomNumberListAndCounter: const [randomNumbers, setRandomNumbers] = useState(() => generateListOfRandomNumbers(length) ); // In RandomStyledNumber: const [isEven] = useState(() => getExpensiveRandomNumber() % 2 === 0);
Note that when we first load the App, its initial render will still be painfully slow. But at least we can tackle slow re-renders easily.
Let's see how this improves the profiler result:
0.9ms - A dramatic improvement!
At this point, you'd normally stop optimizing your app (unless you expect it to grow in complexity). A 0.9ms re-render time is plenty fast. However, for the sake of learning, we're going to optimize the rest of the app.
But notice how all the RandomlyStyledNumber
, ButtonWithFooter
and FancyNumberListFormatter
components continue to re-render. These renders are unnecessary given the only thing that changes is the unrelated counter text.
This where React.memo
comes in. If we pass a component into React.memo
, it will only re-render it if its props have changed between re-renders of its parent. Parent components can re-render all they like, but as long as the props passed to the memoized component remain the same, the cached React Elements will be what's returned.
This is how we call React.memo
:
const MyComponentMemoized = React.memo(MyComponent); // and use it like a regular component in JSX: <MyComponentMemoized />;
So should we wrap all our components in the example with React.memo
?
No. It's neither necessary nor optimal.
Let's take another look at the component hierarchy diagram:
From both the code and the component hierarchy, we can see that memoizing the following components would be redundant:
App
: This is the root component. It's pointless memoizing this as it has no parent component to trigger any re-renders.RandomNumberListAndCounter
: In the context of our example, this component will only re-render when its state,counter
, changes. Its parent component,App
, doesn't also have any state so it would never re-render. Therefore wrapping it withReact.memo
is redundant, as memoized components will continue to re-render when its internal state or context changes (which is what we want).RandomlyStyledNumber
: This component will only re-render when its parent,FancyNumberListFormatter
, re-renders. Therefore, only memoizing its parent is sufficient.FancyHeader
: This component depends oncounter
, as it's passed as a prop. We know that the only thing in our app that triggers a re-render is thecounter
being incremented. Therefore, wrapping this inReact.memo
is redundant - because calling a memoizedFancyHeader
with an incrementedcounter
prop will trigger a re-render since the props have changed.
So only these 2 components would benefit from memo
:
FancyNumberListFormatter
ButtonWithFooter
Both of the above components only depend on the length
prop, and do not care about counter
. When counter
changes, these components end up re-rendering, so they're good candidates for memoization.
Let's go ahead and do just that:
/** First change - wrap our existing components with memo: **/ const FancyNumberListFormatterMemoized = React.memo(FancyNumberListFormatter); const ButtonWithFooterMemoized = React.memo(ButtonWithFooter); function RandomNumberListAndCounter({ length }: { length: number }) { const [counter, setCounter] = useState(0); const [randomNumbers, setRandomNumbers] = useState(() => generateListOfRandomNumbers(length) ); useEffect(() => { setRandomNumbers(generateListOfRandomNumbers(length)); }, [length]); const incrementOnClick = () => { setCounter((prev) => prev + 1); }; const configProp = { name: "Special counter incrementer", listLength: length, }; return ( <div> <FancyHeader counter={counter} /> {/** Then we use the memoized components: **/} <FancyNumberListFormatterMemoized numberList={randomNumbers} /> <ButtonWithFooterMemoized config={configProp} onClick={incrementOnClick} /> </div> ); }
Before we check the profiler, I'm going to throttle my CPU speed by 6x in Chrome - so that it simulates users who don't have a developer grade laptop:
And now let's take a look at the React profiler, where I measure what happens after I click the counter button:
Looking good - all components that don't need to re-render aren't rendering according to this flamegraph (indicated by the grayed out bars).
But the flamegraph isn't showing all the re-rendering components, so let's switch views to "Ranking":
Only two components should re-render: RandomNumberListFormatter
and FancyHeader
. RandomNumberListFormatter
contains the state counter
, which changes, and FancyHeader
takes in the prop counter
.
But it also looks like ButtonWithFooter
is re-rendering, even though its props doesn't include the counter
value.
So why would ButtonWithFooter
re-render despite being memoized and having no props change?
Allow me to yank out the cause of this:
// New object assignment const incrementOnClick = () => { setCounter((prev) => prev + 1); }; // New object assignment const configProp = { name: "Special counter incrementer", listLength: length, }; // Even though semantically the same, config and incrementOnClick // are different objects on each render <ButtonWithFooterMemoized config={configProp} onClick={incrementOnClick} />;
The thing to note is that when React.memo
compares the previous props with the new props, it uses Object.is to compare
if they've changed. This is a shallow comparison - so two objects that have the same values but different memory references,
are not equal (remember {} === {}
evaluates to false
).
And in our example, we're creating new objects for incrementOnClick
and configProp
on every render.
We can easily fix this by using useMemo
, to cache the object references:
const incrementOnClick = useMemo( () => () => { setCounter((prev) => prev + 1); }, [] ); /** * We only create a new object when the `length` changes. */ const configProp = useMemo( () => ({ name: "Special counter incrementer", listLength: length, }), [length] );
And the new Ranked profiler result is:
Now only the two expected components re-render.
But... notice the awkward syntax for the cached incrementOnClick
. We want to cache the function itself, not what it returns.
useMemo
accepts a function, and caches the result of that function. So to get around that, we pass in a function, that returns
the function whose reference we want cached.
Instead, we can use useCallback
instead of useMemo
- its explicit purpose is to cache callback (i.e. function) references:
const incrementOnClick = useCallback(() => { setCounter((prev) => prev + 1); }, []);
Now that we understand how to use memoization in React, in the next article, I'm going to cover how we can avoid memoization!