React Memoization
Learning outcomes:
- What is memoization in React
- What is the
memo()
function - An example of using
memo()
- The comparison callback of
memo()
- The
useMemo()
hook - The
useCallback()
hook - Memoizations must only be optimizations
Introduction
Since its inception, React has grown to cater to a diverse variety of applications, ranging from extremely simple ones, such as your portfolio site, to superbly sophisticated ones, such as Slack, Discord, Twitter, and so on.
Not to mention, this doesn't come without the library exceeding expectations in terms of performance.
On the frontend, performance is more or less everything. Yes this applies to the backend as well, and in general to any form of computing, but the effect multiplies on the frontend. This is due to the very fact that the frontend world is pretty much unknown — all kinds of users surf websites from all different kinds of devices on all different kinds of networks (with different latencies). This diversity of the audience that lies at the frontend of an HTTP communication is the reason why performance matters on the frontend more than anywhere else.
And when we talk about React, since it's also a frontend library (although there are now backend utilities in it as well), performance is right at its core. Reconciliation and the diffing therein, DOM mutations, state batching, React performs all of them quite effectively and efficiently.
But obviously, the re-rendering approach of React can sometimes be problematic, not itself but by virtue of the complexity of our apps. The good news is that even in these cases, React provides helpful utilities at our disposal to optimize the performance of our apps, and save them from re-rendering redundantly and wasting computing resources.
This is the crux of this chapter, where we explore the three optimization utilities in React — memo()
, useMemo()
, and useCallback()
. Altogether, they allow us to perform memoization in React apps to help us sky-rocket an already-performant library to great heights.
Quite dramatic that, isn't it? Let's begin.
What is memoization in React?
In programming, particularly in algorithm design and analysis, the term memoization is quite a popular term, especially when we're working with dynamic programming.
You can inspect more about dynamic programming, which is a paradigm of solving algorithmic problems, but it's quite a deep topic and we won't be going into its details right now. What we're interested in here is the term memoization and what it means.
So let's see what exactly does memoization mean:
The single most important thing to note in the definition above is that memoization is all about caching — saving values in memory — to determine if a computation should be done again or not.
Even though there are multiple ways to implement memoization, the core idea remains the same — caching and then checking.
So does memoization mean the same thing when we see it from the perspective of React? Absolutely yes!
Memoization in React also refers to caching data in memory, to be retrieved later on when the need be. Sometimes, values are memoized for mere retrieval later on while other times they are memoized for retrieval and comparison later on.
Let's start with the main utility that React provides us to help memoize the props of a component in order to prevent re-rendering it when the props remain the same on subsequent renders. That utility is memo()
.
The memo()
function
The memo()
function is an optimization behemoth in React. It holds a great deal of potential, along with useMemo()
and useCallback()
(as we shall see later on in this chapter), to optimize the performance of computationally expensive components.
In simple words:
memo()
is used to create a component in React that does NOT re-render when its props remain the same as before.That's it. Yes, just that!memo()
isn't really any difficult to understand.
Recall from what we've learnt thus far in this course that when a component re-renders in React, everything downstream originating from that component gets re-rendered as well.
For instance, in the following code, if Parent
re-renders, its child Child
would get re-rendered as well, regardless of whether its props are the same as before or not:
function Parent() {
...
return (
<Child {...data} />
);
}
The latter point here is very important to note — React does NOT normally compare props of a component to determine whether it should be re-rendered; it just goes forward and re-renders the component without thinking twice.
But why is this so?
Well it's because in most cases, there are absolutely no performance hits in doing so. That is, there are no performance hits in re-rendering entire sets of elements in React again.
JavaScript is blazingly fast and all this re-rendering happens in the matter of seconds. Also keep in mind that React doesn't mutate the entire DOM upon re-renders; only those things are mutated that actually change in two subsequent re-renders, so the performance hits aren't really that likely since we're not dealing with the DOM that much.
Anyways, coming back to memo()
, just like forwardRef()
, it also accepts a component as its first argument.
Here's the syntax of the memo()
function:
memo(component[, propsEqual])
As just stated, the first argument is the component that we wish to optimize.
However, a second, optional argument propsEqual
can also be provided to memo()
, representing the comparison callback to use to determine if the underlying component should be re-rendered. We'll see how this callback works later on below.
With the syntax clear, it's the high time to see an example of memo()
in action.
A practical example of useMemo()
Let's quickly see an example of memo()
before diving into some of its extra details.
Consider the following SearchForm
component that allows us to search for a given value inside a given list that we provide to the component:
function SearchForm({ list: initialList }) {
const [list, setList] = useState(initialList);
const [query, setQuery] = useState('');
function search(e) {
e.preventDefault();
setList(initialList.filter(
item => item.toLowerCase().includes(query.toLowerCase())
));
}
return (
<>
<form onSubmit={search}>
<input type="text" value={query} onChange={e => setQuery(e.target.value)} />
<button>Search</button>
</form>
<SuggestionsList list={list} />
</>
);
}
The list of values is provided as a list
prop to SearchForm
. When the form is submitted, all the matching suggestions are determined and then displayed inside a SuggestionsList
component.
Here's the definition of SuggestionsList
:
function SuggestionsList({list}) {
console.log('rendering SuggestionsList');
return (
<>
<p>Suggestions:</p>
{list.length ? (
<ul>
{list.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
) : (
<p>-</p>
)}
</>
);
}
The console log is intentionally placed in order to check how many times is SuggestionsList
rendered while we interact with the search form.
As we enter text into the input field, the query
state of SearchForm
changes, ultimately causing it to get re-rendered.
However, much more importantly, <SuggestionsList>
gets re-rendered as well.
This is evident in the logs in the following example as we type into the input field:
For <input>
, it obviously makes sense to re-render it as we type in a new value into it — to showcase the new entered value. However, the same doesn't apply to <SuggestionsList>
.
As we type into the input, list
remains the same and thus <SuggestionsList>
does NOT really need to be re-rendered. However, currently, React does re-render it.
If we have a small list of even a couple hundred elements, this won't pose an issue. But imagine that the list was gigantic, containing somewhere around 10,000 elements, each requiring a lot of processing to be done before it could shown in SuggestionsList
.
With such a huge list, re-rendering the entire <SuggestionsList>
again and again as we type into the input field, while list
stays exactly the same, doesn't seem be a good design decision. We could benefit from optimizing the component using memo()
to prevent re-rendering it when list
stays the same as before.
Let's do that now.
We only ought to wrap the SuggestionsList
component with memo()
and assign the return value of this function to an identifier, which would then act as our new, optimized component.
Following we accomplish this:
const MemoizedSuggestionsList = memo(function SuggestionsList({ list }) {
console.log('rendering SuggestionsList');
return (
...
);
});
For the time being, solely for the sake of explanation, we've renamed the component to MemoizedSuggestionsList
.
In reality, though, we don't need to do so — the component returned by memo()
can be named identically to the component provided to memo()
:
const SuggestionsList = memo(function SuggestionsList({ list }) {
console.log('rendering SuggestionsList');
return (
...
);
});
This approach works because the name of the function expression provided to memo()
— that is, SuggestionsList
— isn't available in the global context.
Anyways, with this memo()
ed component in place, let's run the code again:
As we type into the input field now, there are no console logs made. This testifies to the fact that our new <SuggestionsList>
component isn't getting re-rendered now while we type data into the input field.
That's amazing, isn't it?
Note that we could've also prevented re-rendering <SuggestionsList>
by placing the <input>
element into a custom component, for e.g. <SearchInput>
, and having the input value state query
inside the component.
In this way, as we'd typed into the input, only SearchInput
would've re-rendered. SearchForm
would've obviously kept the list
state as before, but only re-rendered when list
got changed.
In React, there isn't always one way to achieve something — and honestly, there sometimes isn't even the best way. It all depends on what we're comfortable coding and what makes the code readable, flexible, and efficient.
Comparison callback of memo()
Normally, React processes a memo()
ed component by comparing its previous set of props with the new set of props, and then using the outcome of this comparison to determine whether or not to re-render the component.
This comparison uses a shallow comparison, conducted via Object.is()
, to determine if two corresponding props in both the sets of props are the same or not.
Most of the time, this is fine. However, sometimes we need more control over specifying when exactly should two given sets of props compare as being equal or not. And for that, we can leverage the second argument of memo()
.
Recall from the syntax above that the second argument of memo()
, i.e. propsEqual
, is a callback function used to manually compare the previous set of props of a component with its new set of props to determine if the component should be re-rendered or not.
Shown below is the signature of this callback function:
propsEqual(newProps, prevProps)
Both the arguments, oldProps
and newProps
, are objects (following from the very way props are passed around in React) containing the individual props.
This function must return back a Boolean; true
means that the component should re-render while false
means that it shouldn't. Simple.
By virtue of this comparison function, determining whether a component should re-render or not comes down to us, NOT to React itself. We must make sure that when the function returns true
, we really want the component to re-render, and similarly, when we return false
, we don't want the component to re-render.
As simple as that.
shouldComponentUpdate()
method of a class component.Let's consider an example to help us understand this better.
Suppose we have a list of items on an online shopping store's products listing page, each denoted via a ShopItem
component, as follows:
function ShopItem({ data }) {
// The `data` prop is used to create a shopping item view.
return (
...
);
}
The component takes in an item, retrieved from a database, in the form of an object in the data
prop. There is some sufficient amount of processing that needs to be done before rendering a ShopItem
.
Amongst the over-50 fields of data to work with, there is a field called id
on this data
prop, containing a unique identifier for the underlying item in the store's database.
Currently, each time the parent of a ShopItem
gets re-rendered, the ShopItem
component gets re-rendered as well. As we learnt before, this is not a desirable thing to do because there is some considerable amount of computing done for each ShopItem
's rendering.
Now what?How to solve this issue? By wrapping up ShopItem
in memo()
.
Unfortunately, this comes with a problem of its own. In each re-render of the parent of ShopItem
, where ShopItem
gets its data
prop from, the data
object is recreated.
The issue with this recreation is that memo()
would compare the previous data with the new one, mark them as being different (since Object.is()
is used), and thus re-render the component, ultimately making our memo()
optimization effort completely useless.
Any solutions? Well, yes there is: provide a custom comparison function to memo()
.
Let's do that now.
Notice that the only thing that's required to be checked between two given data
objects to determine whether they relate to the same shop item or not is the id
property. If id
is the same, the shop items are the same. Simple.
Let's define memo()
in this way:
function isShopItemSame(oldProps, newProps) {
return oldProps.id === newProps.id;
}
const ShopItem = memo(function ShopItem({ data }) {
// The `data` prop is used to create a shopping item view.
return (
...
);
}, isShopItemSame);
Now, each time the parent of a ShopItem
updates with new data (still possibly containing some of the same items as before), the ShopItem
with its data
prop containing the same id
as before won't get re-rendered, all thanks to the memo()
function and its second argument.
As we said before: memo()
is an optimization behemoth in React!
The useMemo()
hook
Let's now learn about two hooks in React, intrinsically related to working with memo()
ed components: useMemo()
and useCallback()
. We'll start off with the former, which is the simpler one.
useMemo()
is a handy hook to use when we want to prevent performing an expensive routine over and over again in a React component.
Let's define it squarely:
useMemo()
is used to memoize the result of a potentially expensive function.
It's very important to note that useMemo()
does NOT memoize (i.e. cache) a given function; instead, it memoizes the return value of that function.
This memoized value is used so long as a list of dependencies...which you're already familiar...doesn't incur a dependency change.
Let's consider an example where useMemo()
could be really helpful.
Imagine we have a component called FibExpTiming
to compute the total time taken to compute the nth term in a Fibonacci sequence, starting at 0, 1, using a function with a time complexity growing in the same way as the function ::f(n) = 2^{n}::.
Here's the rendered result of the component:
FibExpTiming
component's outputThe input field is used to retrieve the value of n
(remember we have to compute the nth term in the sequence). The checkbox is used to convert the time value into seconds; by default, the time is in milliseconds.
The implementation of fib()
is a pretty simple one, purposely made inefficient to be able to demonstrate an issue in the FibExpTiming
component:
function fib(n) {
if (n === 1) {
return 0;
}
if (n === 2) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
Following we have the naive implementation of FibExpTiming
with a big issue in it. It's recommended that you carefully read through the code as it's a bit longer than usual:
function normalizeDuration(dur, inSeconds) {
if (!inSeconds) {
return dur + ' milliseconds';
}
return (dur / 1000).toFixed(1) + ' seconds';
}
function FibExpTiming({ }) {
const [n, setN] = useState(1);
const [inSeconds, setInSeconds] = useState(false);
function updateN(n) {
n = +n;
setN(n > 25 ? 25 : (n < 1 || isNaN(n)) ? 1 : n);
}
// Time the completion of the fib() function.
let timeStart = new Date();
fib(n);
let dur = new Date() - timeStart;
return (
<>
<input
type="number"
placeholder="Enter a value for n"
style={{ width: 150 }}
value={n}
onChange={e => updateN(e.target.value)}
/>
<label>
<input type="checkbox" checked={inSeconds} onChange={() => setInSeconds(!inSeconds)} />
Convert time to seconds
</label>
{n && (
<p>Time taken: {normalizeDuration(dur, inSeconds)}</p>
)}
</>
);
}
Here's some info worth discussing:
- The
updateN()
function checks the input value and updates the local staten
accordingly to make sure that it's always a number between1
and25
. - The
normalizeDuration()
function takes in the time duration and returns back the corresponding text, in seconds or milliseconds.
Anyways, can you spot the issue in FibExpTiming
?
Well, the issue is that when we check/uncheck the checkbox to convert between the given time formats (seconds and milliseconds), we're technically only wanting to convert the time value between two formats, NOT recompute the nth term all over again!
However, currently, the latter is the case — the highly expensive fib()
function runs again as the checkbox is checked/unchecked.
You can confirm this by opening the following program in the browser and entering the value 25
. Wait a couple of seconds before the output is shown. Then check the checkbox; you'll notice the same time lag after this.
Worse yet, even the time duration changes in this conversion, since the computation is performed and timed all over again.
This needs to change.
The fib()
function should only be re-invoked when n
changes. In case if only inSeconds
changes, the previously returned time must be re-used.
Quite a logical reasoning, right?
Now in order to do so, we seek the useMemo()
function. Let's rewrite the code above using useMemo()
and see how it comes to the rescue:
function FibExpTiming({ }) {
const [n, setN] = useState(1);
const [inSeconds, setInSeconds] = useState(false);
function updateN(n) {
n = +n;
setN(n > 25 ? 25 : (n < 1 || isNaN(n)) ? 1 : n);
}
// Cache the time
const dur = useMemo(() => {
let timeStart = new Date();
fib(n);
return new Date() - timeStart;
}, [n]);
return (
...
);
}
useMemo()
takes in a function that runs fib()
and evaluates the time taken in running fib()
before returning the time. This callback function will only get called when the dependencies of useMemo()
change.
In our case, there is just one dependency: n
; hence, whenever n
changes, useMemo()
would re-run its callback and re-store its return value.
We return the time from the callback function because that is what we need to use in the FibExpTiming
component. If we needed to use something else, we'd had returned that very thing.
Anyways, let's now test the component by actually running the program:
As we check/uncheck the checkbox now, notice how quickly the time changes, clearly indicating that as we perform this check/uncheck action, the expensive fib()
function gets invoked just once.
What a great tool is useMemo()
. What do you say?
The useCallback()
hook
Let's now talk about the very similar hook, useCallback()
.
useCallback()
hook is merely a special case of useMemo()
, used in order to memoize a function value.And that's exactly where the name 'useCallback' comes from — we are caching a 'callback'.
Let's quickly consider a basic example.
Say we have an App
component defining a local function callback()
and rendering a memo()
ed component, MemoizedComponent
:
function App() {
function callback() {
console.log('Hello World!');
}
return (
<MemoizedComponent callback={callback} />
);
}
Since MemoizedComponent
is a memo()
ed component, it is necessary to keep the value of callback
the same at all times before providing it to the component, to leverage the potential of memo()
.
Otherwise, callback
would be different in every single instant, and the component would be re-rendered again and again, even though the callback
function itself isn't changing in terms of its definition.
To solve this issue, we just need to cache the function so that every time we get back the same function reference in return. A naive approach would be as follows, utilizing useMemo()
:
import { useMemo } from 'react';
function App() {
const callback = useMemo(() => {
return function callback() {
console.log('Hello World!');
};
}, []);
return (
<MemoizedComponent callback={callback} />
);
}
Fortunately enough, React already knew of such use cases, and likewise provided useCallback()
as a quick utility to accomplish the exact same thing as demonstrated above.
The syntax of useCallback()
is as follows:
useCallback(callback, dependencies)
callback
is a function to cache (not a function to run and cache its return value; that's the job of useMemo()
) and dependencies
is a list of dependencies to probe to determine if the cache needs to be refreshed.
useCallback()
is NOT invoked by React unlike the callback provided to useMemo()
; it is merely just cached.Let's rewrite the code above using useCallback()
now:
import { useCallback } from 'react';
function App() {
const callback = useCallback(function callback() {
console.log('Hello World!');
}, []);
return (
<MemoizedComponent callback={callback} />
);
}
Now every time the parent gets re-rendered, the memo()
ed component doesn't.
That's simply because we're using useCallback()
and that returns the exact same function to us on each invocation (due to the empty dependencies
list), thus making the callback
prop of <MemoizedComponent>
the same in every single render.
Memoizations must only be optimizations
Before we end this chapter, there's one paramount point to clear regarding all three of the functions discussed above, which we collectively refer to as 'memoizations'.
Here's that point:
There's quite a lot summarized in these two sentences.
We might be tempted to use memo()
, useMemo()
and useCallback()
in ways purely for 'getting a job done', whereby without them, the code doesn't work correctly at all.
This is completely undesirable and absolutely NOT what these functions are meant for!
memo()
, useMemo()
, and useCallback()
are optimizations to be done in React code, and as with all kinds of optimizations in programming, they must NOT be the difference between correct and incorrect code.
Optimizations must always be used to make an otherwise correct piece of code more efficient and performant. They aren't meant to make code correct!
This is way easier said than done.
Another related idea that follows directly from this one is that optimizations must only be done when there is a need to. As Donald Knuth said,
Premature optimization is the root of all evil.
In this respect, always start by creating components with the basic principles.
Test the performance of those components and if you really notice a bottleneck somewhere, then determine the cause of it and finally optimize the components using memo()
, useMemo()
, and useCallback()
, where possible.
You must NEVER begin coding a component with these optimization constructs, no matter how cool they seem!
Sometimes, such optimizations in userland code might kill off the potent optimization power that JavaScript's sophisticated engines hold themselves and waste a lot of useful resources.
Spread the word
Think that the content was awesome? Share it with your friends!
Join the community
Can't understand something related to the content? Get help from the community.