React is a front-end Javascript library that allows for seamless creation of component-based single-page applications (SPAs). As a web developer, you have likely used React or at least heard of it at some point in your career. 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, you may have realized that the world beyond basic React is vast. If you want to build performant web applications, especially for data-heavy use cases, you have to pull your socks up and start thinking like a real software engineer. 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. I will start with a quick refresher on the following concepts:
Memoization
React component function revaluation
I 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 optimization technique that employs a cache which stores results directly mapped to inputs for a function that produces those results. When the function is invoked with previously seen inputs, then, instead of running computations, the cache is used to return results directly. This consequently speeds 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 index n.
Benchmark sumNumbersFrom with output: Result: 45004814; Duration: 75ms.
A wrapper function memoizeFunc for producing memoized version of its input function originalFunc . The wrapper establishes a cache cache which is retained by the returned memoized function through its closure. The returned function also take an argument input which should be identical to input required by originalFunc . The cache is first checked to see if input was previously stored, if not, then originalFunc is used to compute the result based on input which is then stored in cache . If input was found in cache then its associated result is returned directly without invoking originalFunc resulting in an O(1) operation.
The wrapper function memoizeFunc is applied to sumNumbersFrom to create its memoized counterpart memoizedSumNumberFrom.
Benchmark execution of memoizedSumNumberFrom for both scenarios when results were uncached and cached (for input = 100). We see that in the instance when the result was uncached, the function took 75ms to run since the original sumNumbersFrom had to be invoked to compute the result. However, the second time when the same input of 100 is passed to memoizedSumNumberFrom, the cached result is returned immediately resulting in 0ms execution time.
From the above example, you can see that memoization is a neat technique for optimizing the time complexity of our functions.
Note: Components in React are really just functions and memoization is perfectly suited to improve the rendering speed of your 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, you should consider implementing this optimization for computationally intensive problems rather than every little task you encounter!
React Component Evaluation
In this section, you will see the reason that optimization is required, especially for the ways in which React evaluates component functions, as well as their children components in an application.
Fig.1 Example React App Structure
The above image depicts a very simple React app hierarchy, each block is a component and they are set up in a tree-like structure. The critical aspect to realize 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 components will be evaluated regardless of any dependencies that exist between the component and its children. To put it simply, React does not care if, for example, Child 1 depends on any state change in Parent and as long as Parent is evaluated then Child 1 will be evaluated. The same goes for Child 2 and Child 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 set up a React project.
However, if you want a refresher on how to set up a React project, how the main app component works and gets rendered in the DOM, please refer to the code links provided above.
Fig.2 Browser console output from Revaluation component
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 realize 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.
Fig.3 Browser console output from MemoExample component
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 behavior 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 change 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 the shallow comparison default, we can pass a comparison function as the second argument to React.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 behavior. 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, the same as objects and arrays. Therefore, shallow comparisons of functions will always result in false returns. The below example demonstrates the behavior of React.memo when used for a component with function props.
Fig.4 Browser console output for MemoAndCallback component with functional 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 function that is invoked whenever Child2 is rendered.
Note: Child2 is wrapped in a React.memo call, but, as you can see from the console output in Fig.4, Child2 is rendered for every state update in MemoAndCallback. This is because 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.
Fig.5 Browser console output for MemoAndCallback component with useCallback
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 optimizing 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 optimization; code below demonstrates its usage.
Fig.6 Browser console output for UseMemoExample component
The UseMemoExample component has a single state reevaluate, 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, displaying the result 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 optimize our code, below is a demonstration.
Fig.7 Browser console output for UseMemoExample component with useMemo
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, I discussed what memoization is and how you can use relevant tools provided by React to improve the performance of your applications. 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.
About the Author
Xiao Ming (Mason) Hu has a Masters degree in Electrical Engineering. He loves expanding his knowledge and is into fitness and cryptocurrencies.