Optimizing React: Part 3 - Avoiding Memoization
Table of Contents
This is the final article in a series covering techniques for optimizing React's performance by minimizing renders.
- Part 1 - Understanding Renders
- Part 2 - Understanding Memoization
- Part 3 - Avoiding Memoization (this post)
In the last post, we worked through an example application and used memoization to reduce unnecessary renders. However, as I said in the very first article of this series, there's a small cost with memoizing. And these costs could add up significantly if we have tonnes of components being memoized.
So let's see how we could re-write our memoized example application from the previous article in a way that minimizes usage of memoization.
Using useRef to replace useCallback or useMemo
In our example from the last post, we can see we're calling useCallback
with an empty dependency array:
const [counter, setCounter] = useState(0); const incrementOnClick = useCallback( () => { setCounter((prev) => prev + 1); }, // We don't pass anything into the array, and the React linter // does not complain. [] );
We can do this because React
guarantees setCounter
to be stable.
For scenarios where we have an empty dependency array for useCallback
or useMemo
, we could just store the variable in a ref instead.
Note that this only applies for useMemo
where you're using it for a stable reference, and not to reduce expensive computations.
So our example can be re-written as follows:
const incrementOnClick = useRef(() => { setCounter((prev) => prev + 1); }); <ButtonWithFooterMemoized config={configProp} onClick={incrementOnClick.current} />;
useRef
returns a reference to a mutable object. Like in useState
, the initial value you pass is assigned
only once, no matter how many times the component re-renders. How useRef
works is beyond the scope of this post, but you can read more about it here.
This technique is common in React libraries where returning stable references from hooks is important.
Since incrementOnClick.current
is a mutable object, its value will only change if we explicitly update it - so we don't need to care about re-renders of the component re-assigning it to a different value.
The only thing to watch out for, is that since it's a mutating object, React has no idea if it has changed - which is fine.
Usually we only want React to know if a variable has changed when we synchronize with effects or display the data - that's why we use
hooks like useState
.
Defining variables outside a component
Not everything needs to go into useState
, or needs to be defined in a component. Too often, I find code like this:
function Component() { const doABunchOfStuff = () => { const result = 1 + 1; console.log("result", result); }; const config = { title: "Beautiful example", theme: "skyblue" }; return <SomeotherComponent work={doABunchOfStuff} config={config} />; }
The key point of the above code is that doABunchOfStuff
and config
isn't referencing any data or functions defined in the component.
Therefore, it does not need to tie itself to React's rendering.
We can throw away concerns of stable references and garbage collection by just defining doABunchOfStuff
and config
outside of the component:
const doABunchOfStuff = () => { const a = 1 + 1; console.log("result", a); }; const config = { title: "Beautiful example", theme: "skyblue" }; function Component() { return <SomeotherComponent work={doABunchOfStuff} config={config} />; }
Pushing State Down
There is one more technique I'd like to dive into, and it's one where you can minimize having to use React.memo
. This technique involves restructuring your component tree hierarchy, so that state changes are localized to components that display them. Components that are meant to be rendered at the bottom of the tree, are passed down as children
.
It's best understood by re-writing our example.
To refresh your memory, this the current component hierarchy of the example app:
We are going to change the hierarchy so that less components need to re-rendered on a state change.
First, let's put all the code related to the counter
state in one component, called FancyCounter
:
const ButtonWithFooterMemoized = React.memo(ButtonWithFooter); function FancyCounter({ length, children }) { const [counter, setCounter] = useState(0); const incrementOnClick = useRef(() => { setCounter((prev) => prev + 1); }); const configProp = useMemo( () => ({ name: "Special counter incrementer", listLength: length, }), [length] ); return ( <div> {/** Note that we now will only render FancyHeader and ButtonWithFooterMemoized, but not FancyNumberListFormatter - we instead only include a reference to some children passed down by the parent component. */} <FancyHeader counter={counter} /> {children} <ButtonWithFooterMemoized config={configProp} onClick={incrementOnClick.current} /> </div> ); }
This is the key point: now, FancyCounter
will only re-render when setCounter
is called - when this happens, its children
prop
will be unchanged. children
consists of React Elements passed down from the parent - as the parent has not re-rendered, these
objects remain unchanged - and React is smart enough to know that these do not need to be re-rendered.
This is how the rest of the re-written components look like:
function RandomNumberListAndCounter({ length }: { length: number }) { const [randomNumbers, setRandomNumbers] = useState(() => generateListOfRandomNumbers(length) ); useEffect(() => { setRandomNumbers(generateListOfRandomNumbers(length)); }, [length]); return ( <FancyCounter length={length}> {/** Note that this is no longer memoized! */} <FancyNumberListFormatter numberList={randomNumbers} /> </FancyCounter> ); }
We pass in FancyNumberListFormatter
as the children
of FancyCounter
.
RandomNumberListAndCounter
will only re-render if its parent changes,
and in our app, this will never happen. So React.memo
for FancyNumberListFormatter
is no longer needed!
This is what the new component hierarchy now looks like:
We can see that the React Profiler gives the same results:
This technique was lifted straight from Dan Abramov himself - and I highly recommend reading his blog post on it.
The basic idea is that the prop children
, is a reference to some React elements created by the parent.
If the child component has re-rendered, and the children
prop remains the same, React knows that children
doesn't have to be rendered again.
Wrap Up
I do want to emphasize though, that the optimizations I've mentioned above could make your code a little harder to read. In a lot of application code, readability and maintainability are higher concerns than performance. Libraries on the other hand need to emphasize on having more performant code.
So ideally, you'd use these optimizations when you need to, and not simply for the sake of "performance".
And now below, I will summarize everything we've learnt in this entire series.
When we want to create stable references, we can:
- Define objects and functions outside of a component, if they don't rely on variables defined in the component itself.
- If they do depend on variables defined in the component, we can use
useCallback
anduseMemo
. - If they depend on variables defined in the component that are already stable, we can use
useRef
instead.
When we want to prevent redundant expensive computations on re-render, we can:
- Pass an initialization function to
useState
oruseReducer
, if the result of the computation needs to be part of the component state. - Use
useMemo
.
When we want to minimize redundant renders of a component, we can:
- Use
React.memo
so that a component will only re-render if its props change. - Reorganize the component tree, so that state changes are localized to components who consume that state.
In our final optimized example app, we have:
- One component wrapped with
React.memo
- One object wrapped with
useMemo
- One callback stored in
useRef
A more naive approach would have been memoizing every component, object and callback in the app.