Optimizing React rendering with hooks and other fine gems
Lately I found myself doing a bit more front-end work, possibly too much for my taste. I have been dealing with React for the past five years but it’s always useful to come back to it and find new paradigms and new tools. In this post, I would like to remind everyone else who is also dealing with the same technology that there are some best practices to follow for optimizing your renderings.
Functional components
Let’s start with a simple example. You can read the the code in the following sandbox. You can access the whole project through the side menu via the burger button on the top left:
For now let’s just focus on the code for NaiveComponent
and ButtonContainer
. The first thing to notice is that these two, along with all other components in the application, are written as pure functions. The functional trend has been raging on for a few years now, for better or for worse. Disregarding personal preferences, functional components have objective benefits:
- Removing syntactic pollution from the code;
- Allowing the extraction of state-related code into reusable hooks;
- Performance boost in terms of reduced package size and dom tree optimizations.
I don’t intend to extensively illustrate the difference between the functional and classical approach, since everybody has already done so. It just felt noteworthy to mention, as it is a simple yet effective way to start and improve your codebase.
Programmatic profiling
Going back to the sandbox, you will notice I am using a React utility called Profiler to inspect how the dom tree associated to ButtonContainer
is rendered during the application lifecycle. In order to do that, I allowed myself some liberty with how the code is written: the number of re-renders is printed onto the console, and it only increases further from the first mount. I preferred to have it as an “invisible” side effect, and not displayed in the UI, as doing so would cause even more re-renders! Anyhow, the point is it allows you to easily observe how changes from outside (namely the ButtonContainer
props) affect the rendering process. ReactDevTools also provide visual feedback and flame graphs for detailed profiling, but those won’t be available to you through the embedded sandbox I am using 😺
The NaiveComponent
simply displays an input box and a button side-to-side. The input box accepts some text and the button’s content will change depending on whether said text is shorter or longer than 10 characters. Pressing the button will just pop-up an alert with the same content every time. Quite simple.
The intent is for the button to only be re-rendered twice as users type longer and longer texts in, since it only depends on the internal component state via the expression “is value longer than 10 characters?”. However, that is not the case. Instead of simply using a string as the button content, I used a “blob” object whose id can be tracked. This is an artificial way to simulate more complex data structures that behave in the same way, even though it may seem inappropriate for this use case.
As you type into the textbox, the id will keep changing, making it obvious that a lot more than two re-renders happen! Additionally, if you try and input the text “hi there, how are you?”, the console message will report that NaiveButtonContainer
has been re-rendered 25 times.
This behaviour can be avoided by making sure that props passed to ButtonContainer
only change when it’s strictly needed. Since inline functions are evaluated every time a component is invoked, they are never constant and always produce different complex objects, resulting in continuous re-renders. Memoization allows you to prevent excessive evaluations by remembering older values and reusing them when appropriate.
Memoization and hooks
Memoizing the result of a function stores the former in memory. Later on, when the function is called again, if its parameters match any old set of parameters that have previously been seen, the stored result will be returned instead of invoking the function. Memoization is different from caching in nature as it can only be applied to pure functions, i.e. functions whose output depends exclusively on their input parameters (they don’t have side effects). Therefore, it’s an exact technique that never compromises soundness for performance, while caching may do. Caching relies on heuristics to provide good enough results most of the time and requires manual invalidation to enforce exactness. Additionally, simple memoization libraries may not implement eviction policies, expiry times and all the other resource-balancing goodies that caching often provides. In both cases, though, remember that you are sacrificing memory for increased speed.
Within the React framework, there are three main memoization utilities:
- useMemo accepts a function F and a set of dependencies D. The result of F is memoized depending on D;
- useCallback accepts a function F and a set of dependencies D. F itself is memoized depending on D;
- React.memo automatically works on entire components. It accepts a React component and returns an equivalent component that is memoized depending on its props.
By using these in combination, we can prevent the props of ButtonContainer
from changing too often, thereby reducing the total number of re-renders. If you type the same sentence as before in the second text box, you will notice the id changing only once and the console reporting only two re-renders:
However, there’s still a very interesting point to make. In fact, the React documentation is vastly misleading, as all the utilities provided do not implement real memoization! They just implement a simple logic: if the current parameters are equal to the last seen parameters, then return the memoized values, otherwise recompute it. You can easily witness this behaviour by typing a long sentence in the second input box and then deleting it: if you pay close attention to the ID displayed within the button, you’ll notice it changes despite useMemo
being called on the same value of excessiveLength
! For example, try and place a call to console.log in the MemoizedComponent body:
1 | false, Object {id: "106390", text: "OK!"} |
See? Same boolean value, different memoized results! Thus useMemo
and other similar utilities are nearly useless when the parameters change often!
You may also notice that each change of the input value causes two calls to MemoizedComponent
, but zero to one re-renders, and each call has different memoized values despite a constant dependency set. This is caused by the usage of React.StrictMode, which is a wrapper used in development environments to provide additional checks aimed at spotting potential problems with components’ life cycle. Checks are performed by invoking some methods twice and making sure no weird side effects linger. The body of a function component is one of those methods. If you remove the usage of React.StrictMode
in index.tsx
, you’ll no longer get double invocations!