Enhance React Performance with Memoization
This is the very first article that I am posting on Medium, and for almost two years I have been brooding over what it was going to be. It didn’t turn out to be the cure for covid or some concretely abstract story talking about the meaning of life, but rather I have decided that I want to share my learnings and experiences with you as a software engineer. The journey of a self-taught developer can often be lonely and frustrating, I suppose through the act of sharing this somewhat makes me feel connected. So let’s grow together 😁
Introduction
React is a front-end Javascript library that allows for seamless creation of component-based single page applications (SPAs). As a web developer in 2022, it is likely that we have used React or at least heard of it at some point in our careers. If your experience with React was like mine, it was easy to get up to speed with the basics such as controlling states with useState
or managing side-effects with useEffect
. However, soon you realised that the world beyond basic React is vast and that if we want to build performant web applications, especially for data heavy use cases, we have to pull our socks up and start thinking like real software engineers. Therefore, learning about the right tools is essential to improving the performance of your React application.
Today I would like to share with you some tricks I’ve picked up along the way, specifically how to enhance a React application using memoization. We will start with a quick refresher on the following concepts:
- Memoization
- React component function revaluation
Armed with the above knowledge, we will then look at how it is implemented in React with the following tools:
React.memo()
useCallback()
useMemo()
Memoization
Memoization is just a fancy word for caching. It is a widely used concept in dynamic programming for code optimisation. You can find an official explanation here, however, here is a short definition before we dive into the code:
Memoization is an optimisation technique that employs a cache that stores results directly mapped to inputs for a function that produced those results. When the function is invoked with previously seen inputs, then instead of running computations the cache is used to return results directly and therefore speeding up the function execution.
The code below demonstrates memoization in Javascript with a simple scenario and some benchmark results.
- Generate an array of 10 million random numbers between 0–10 for testing purposes. This is defined globally and used as test data in the following functions.
- Setting up some testing metrics for benchmarking functions.
- Simple function that adds up all numbers in
data
from indexn
. - Benchmark
sumNumbersFrom
with output:Result: 45004814; Duration: 75ms
. - A wrapper function
memoizeFunc
for producing memoized version of its input functionoriginalFunc
. The wrapper establishes a cachecache
which is retained by the returned memoized function through its closure. The returned function also take an argumentinput
which should be identical to input required byoriginalFunc
. Thecache
is first checked to see ifinput
was previously stored, if not, thenoriginalFunc
is used to compute the result based oninput
which is then stored incache
. Ifinput
was found incache
then its associated result is returned directly without invokingoriginalFunc
resulting in anO(1)
operation. - The wrapper function
memoizeFunc
is applied tosumNumbersFrom
to create its memoized counter partmemoizedSumNumberFrom
. - Benchmark execution of
memoizedSumNumberFrom
for both scenarios when results was uncached and cached (for input = 100). We see that in the instance when result was uncached, the function took75ms
to run since the originalsumNumbersFrom
had to be invoked to compute the result. However, the second time when the same input of 100 is passed tomemoizedSumNumberFrom
, the cached result is returned immediately resulting in0ms
execution time.
From the above example, we can see that memoization is a neat technique for optimising the time complexity of our functions. We should note at this point that components in React are really just functions and that memoization is perfectly suited to improve the rendering speed of our React application. It is also important to note that memoization comes at the cost of memory for storing the cache and computations required to convert function inputs into keys for caching. Therefore, we should consider implementing this optimisation for computationally intensive problems rather than every little task we encounter!
React Component Evaluation
In this section, we will see the reason why optimisation is required especially for how React evaluates component functions as well as their children components in an application.
The above image depicts a very simple React app hierarchy, each block is a component and they are setup in a tree-like structure. The critical aspect to realise about the above structure is that:
React will evaluate/re-evaluate a component when either a state, prop or context change is registered in that component.
When React evaluates a component function, all of its children component will be evaluated regardless of any dependencies that exists between the component and its children. To put it simply, React does not care if, for example,
Child 1
depends on any state change inParent
and as long asParent
is evaluated thenChild 1
will be evaluated. Same goes forChild 2
andChild 3
.
To demonstrate the above concept, I have created a simple app using create-react-app and the code can be accessed here.
Note: I will be going through only the relevant code sections of the app in this article. It is assumed that everyone who is interested in improving React performance is familiar with how to setup a React project. However, if you want a refresher on how to setup a React project, how the main app component works and gets rendered in the DOM, please refer to the code links provided above.
The above React code uses a Revaluation
parent component to demonstrate the structure shown in Fig.1. The parent component contains three child components Child1
, Child2
and Child3
of which Child2
is nested in Child1
. The Revaluation
component contains a single boolean state toggle
which can be updated by toggleHandler
.
It is important to note here that changing the toggle
state will trigger a re-evaluation for the Revaluation
component as well as all its children. Furthermore, neither Child1
, Child2
or Child3
is dependent on the state change in Revaluation
. Take Child1
for example, it takes in a show
prop that is hardwired to false
but the console output shown in Fig.2 clearly indicates that Child1
is re-evaluated every time the state is changed in Revaluation
. The same observation can be said for Child2
and Child3
i.e. the logic in those child components has nothing to do with the logic in their parent component.
I hope at this point you have come to realise that this section’s example demonstrates a potential performance bottleneck in React applications. In the real world, Child1
, Child2
or Child3
may contain computationally intensive logic. Re-evaluating these child components every time due to an unrelated parent component state update is wasteful.
This is where Memoization comes to the rescue. As explained in the earlier section, we can use this optimisation technique to cache results returned from a function based on its inputs. React applications can be sped up using Memoization as they are nothing more than just a bunch of functions nested in a tree-like structure!
React.memo
The first tool we will discuss is React.memo
. It is a Higher Order Component (HOC) used to wrap any React functional component for memoization. The code below demonstrates an example for its usage.
The example consists of a parent component MemoExample
which contains only a single child component Child1
and a state toggle
which can be updated by toggleHandler
.
Furthermore, we wrapped Child1
inside of a React.memo
call to memoize the child component upon its first render in the DOM i.e. the html syntax tree output by React for Child1
is now cached in memory and mapped to its props. For every subsequent state update registered in MemoExample
, React checks the Child1
props for any changes, if no changes took place then the cached output for Child1
will be rendered instead of evaluating the component again. Since Child1
only takes in a single prop show
and we have hardwired it to false
, this means Child1
will never be evaluated again after its first render regardless of any state changes in MemoExample
. This behaviour can be seen in the console output demonstrated in Fig.3 where the console.log
in Child1
only ran once and never again for every subsequent changes to the toggle
state in MemoExample
.
At this point I know what you are thinking, why don’t we just apply React.memo
to every component that ever existed under the sun? Well, remember in the section earlier it was mentioned that there is a cost to Memoization? Let us quickly examine some of the caveats of React.memo
in order to understand why it is actually a bad idea to use it always:
- It costs memory to memoize a React component since we need to cache the rendered result as well as props for that component.
React.memo
only checks for props changes, so for internal state changes the wrapped component will still be evaluated.- The props check with
React.memo
is by default a shallow comparison, meaning that only changes in primitive data types (strings, booleans and numbers) will be properly registered as more complex data types such as arrays, objects or even functions are reference types. To overcome shallow comparison default, we can pass a comparison function as the second argument toReact.memo
to tell React how the props should be compared exactly. - If props for a component contain a lot of data, the comparison cost as well as memory cost may become prohibitive to use
React.memo
. These trade-offs for speeding up your application should be carefully considered.
useCallback
Before we move on to the next memoization tool in React, let’s address a detail mentioned in the previous section. When using React.memo
on components with props that are complex data types, we need to work around the shallow comparison default behaviour. For objects and arrays we can use the comparison function second argument input for React.memo
but what if a prop is a function? In Javascript, functions are reference data types which are the same as objects and arrays, therefore shallow comparison for functions will always result in false returns. The below example demonstrates behaviour of React.memo
when used for a component with function props.
The above MemoAndCallback
component is a simple extension of the MemoExample
component from the previous section. A Child2
component was added which takes in a single function prop func
that is invoked whenever Child2
is rendered. Note that Child2
is wrapped in a React.memo
call but as we can see from the console output in Fig.4, Child2
is rendered for every state update in MemoAndCallback
. This is due to that the function child2Func
is declared for every render of MemoAndCallback
, causing it to have a different memory location allocated each time. Because functions are reference types and React.memo
compares the newly declared child2Func
memory address with the cached child2Func
memory address, this causes Child2
being rendered even when its props effectively remains the same for every parent state update.
This is a common problem encountered when using React.memo
or any other techniques that require checking data dependencies across render cycles. Due to this, React actually has a hook that addresses this issue and that is the useCallback
hook. Below is a modified version of MemoAndCallback
component demonstrating its usage.
Here we added yet another component Child3
inside MemoAndCallback
also with a functional prop func
. The function child3Func
is passed to Child3
, however, its declaration is wrapped inside an useCallback
hook. The concept of useCallback
is exactly the same as React.memo
, the actual function being wrapped is cached and if none of the dependencies specified in the second array argument of useCallback
changed then the exact same function from the previous render cycle is returned. Using this technique we can ensure that the exact same function, stored in the same memory address, will be passed to a component as a prop. Therefore, React.memo
will be able to correctly check props of a component even if they are functions. From the console output in Fig.5, we can see exactly as described above that Child3
only renders once and never again since child3Func
remains the same for every state update in MemoAndCallback
component.
useMemo
Aside from optimising component functions, React also provides a tool that allows us to memoize any complex expressions that produce some result. The useMemo
React hook can be used to wrap code we call inside a component for optimisation, code below demonstrates its usage.
The UseMemoExample
component has a single state revaluate
which can be updated by revaluateHandler
. A global data
object consisting of 1 million random numbers is defined outside of the component to mimic persistent data passed to the component. The global data
is sorted for every render of UseMemoExample
. This sorting operation is further benchmarked and the result displayed in ms within the component. From the output shown in Fig.6 we can see that data
is sorted in approximately 200ms every time UseMemoExample
is updated. In reality, this sorting operation can be replaced by even more complex logic and could cause the component to render with sub-optimal performance. This is where we can use useMemo
to optimise our code, below is a demonstration.
The only change we introduced in UseMemoExample
component is moving the sorting logic for data
into a useMemo
hook as a callback where the second argument for the hook is a list of dependencies to be checked for memoization. In this example data
is a global constant and therefore should not be considered a dependency. We can see from Fig.7 that when UseMemoExample
is first rendered, data
is sorted in 205ms and cached by useMemo
. For every subsequent state update since no dependencies have changed, the cached and sorted data
object will be used and therefore resulting in a huge speed up in rendering speed for the UseMemoExample
component.
It should be noted that when implementing useMemo
for improving your React components, we should consider the exact same pros/cons mentioned previously for React.memo
. If implemented for poor use cases, memoization may result in worse performance due to additional space complexity as well as time required for checking function inputs!
Summary
In this article we discussed what is memoization and how we can use relevant tools provided by React to improve performance for our applications. Specifically React.memo
and useMemo
were covered and explained in detail. The need for useCallback
was also discussed for implementing memoization where functions are included in the memoized function inputs. Each concept was explained and demonstrated using code examples so that we can see exactly how these tools operate in the browser.
Phew!! That’s a lot of information 😅 but hopefully all concepts were explained with enough detail and examples to assist you on your React journey. This being my very first attempt at blogging my development journey and I am almost certain that there may be insufficient areas in my writing, therefore I welcome all the criticism and advice for improvements! Thank you.