React performance optimization with hooks

This article demonstrates how React hooks affect rendering of components. The goal is to figure out how different mechanics interact with each other which gives tools to deal with performance issues.

For this purpose, I have created a demo application. The source code can be found at https://github.com/JereKuusela/react-performance-demo. The application is also hosted at https://jerekuusela.github.io/react-performance-demo.

Overview of the application

The application consists of multiple tabs which demonstrate different kinds of problems. Most examples use Redux to add an extra layer of complexity. Some examples also demonstrate the use of Redux. For this reason, each example has an unique identifier which is used to give them a separate state. The purpose of these examples is to show how components render in different situations. This is done by changing the background color on each render.

In many places, the code style is way too optimized. This is only done to make the examples work and not something you should aim in your own code. React is fast and worrying about every render won’t be productive.

Rendering

The first tab covers the basics of rendering without using hooks. The first example shows one of the major sources of React performance issues. By default, rendering of a parent component will also render its children. This causes all inputs to render even when their value doesn’t change.

This is fixed on the second example where only the changed input gets rendered. The difference is that the inputs are wrapped in memo() which makes them only render when props change.

The third example shows that using memo() won’t automatically solve all issues. In this case the props are delivered as an object, containing both the first and the last name . Updating a value creates a new instance of the object which causes both first and last name inputs to render.

State

The second tab demonstrates state management. In these examples there are buttons that increase an internal value. These values are not used at all but some ways still cause renders. Components are not memoized to allow tracking forced renders.

The first example stores the value with useState. All clicks will cause a render because useState forces a render on update. The second example stores the value with useRef. Clicks no longer cause a render because the value of useRef is only mutated. The third example shows that useState can also be used like useRef. Mutating the state won’t trigger renders but for the sake of consistency it’s better to use useRef for mutable data.

The fourth example stores the value in the Redux store. This doesn’t trigger renders. However on the fifth example the stored value is accessed with useSelector. This forces renders even when the value is not used. The last example demonstrates usage of useReducer which also forces renders.

Selectors

The third tab demonstrates how more universal selectors cause more renders. The useSelector forces the component to render when the returned value changes, even if the component doesn’t actually use the returned value. This makes it important to only return what you exactly need from the Redux state. In all examples, the inputs get the data directly from the Redux store instead of from props as in the previous examples.

In the first example, the useSelector returns all of the name data (including data from other examples). This causes all inputs to be rendered whenever the state is updated (even when editing other examples). This gets slightly better on the second example where useSelector returns name data only for one instance. Inputs still render on change but changes in other examples won’t affect them.

The third example returns the whole object. In this case the row renders, just like in the first tab where the object was used in props. In the last example only the needed attribute is returned which means only the changed input will render.

Handlers

The fourth tab covers the memoization of handlers with useCallback. Every example has a numeric input, a button which adds the input value to Redux store and an input that shows the Redux store state. The rendering of the button is tracked. In most examples the components are memoized and the input value is stored with useState.

The first example shows what happens when components are memoized. Every render creates a new button click handler. So both changing the input value and clicking the button causes a render. The second example shows what happens when the button handler is memoized with useCallback but components are not. The useCallback makes no difference because the button is always rendered because the parent renders. The third example is memoizing both components and the click handler. However the click handler has a dependency on the input value so the button still renders when the input value is changed.

Next examples show ways to break this dependency. The fourth example uses the state updater function to get the input value. The fifth example copies the input value to a mutable ref which is then used on the handler. The sixth example uses a ref to store the whole callback and then the handler only calls this callback.

The last examples use reducers. The seventh example stores the input value in Redux store so the button handler no longer needs the input value at all. The eighth example does the same with useReducer. The ninth example also uses useReducer but passes down the dispatch in a context which removes the need of passing down the handler as a prop. Same thing would also work with Redux.

Values

The fifth tab covers useMemo which can be used to memoize references or results of expensive calculations. In these examples there is an additional text field for showing the full name.

The first example delivers the first and the last name as an object to the full name field. Without memoization, this object is created every render which causes all full name fields to render. The second example fixes this by memoizing the object with useMemo.

The third example demonstrates that useMemo is not needed when the first and full names are delivered as strings. Because no objects are used, inputs only render when changed. The fourth example uses useMemo but without memoizing the text fields. This shows that useMemo has no effect if the components are not memoized.

The fifth example is using Redux useSelector to return the data. This has the same issue as in the first example. The useSelector creates a new object so the return value always changes. The sixth example fixes this by changing the equality function which useSelector uses for memoization. In this case the return values are compared by using shallow equality which prevents extra renders.

The seventh example shows what happens when the full name text field is not memoized. The parent will render the text field which seems to make useSelector to forget the previous value. The last example demonstrates that this issue is not present in the Reselect library which provides a different way of creating selectors.

Reselect

The last tab covers usage of the Reselect library. It allows memoizing selector values based on the input values. This can be used to avoid expensive calculations and also reusing complex selector logic. In these examples, the full name text fields includes a numeric value to show how many times this “expensive” operation has been performed.

The first example shows the result without Reselect. Changing a value seems to result in 9 new objects which shows that useSelector is clearly not the right place for expensive operations.

The second example is using Reselect but somehow the result is only slightly better, with 6 new objects created. This is because the selector instance is shared among all full name fields. By default the selector has a cache size of 1 so it only remembers the last result. The selector will almost always do the operation when used for two or more components because input values are rarely the same for different components. This can be tested by writing the same values to both rows. The last example fixes this by creating a selector instance for each full name field. Only the changed field updates and only one operation is performed.

Conclusions

The demo application shows clearly how different mechanics interact with each other. This allows drawing some conclusions how things should be used.

Most of the time, multiple optimizations are needed for performance improvements. Memoization of handlers and objects only makes sense if the component is also memoized and renders are caused by the parent component. Memoization is also useless if the dependencies or props change every render or if the render is caused by a state update. Optimizing these requires extra steps and may not even be possible in some cases.

References

There are two distinct ways of handling references. One way is to rigorously apply useMemo and useCallback to keep all references stable. Relying on the react-hooks/exhaustive-deps eslint rule helps with this. The advantage of this approach is safety. Unstable references can cause nasty bugs and to avoid them you need to know how things work. For new developers, it can be easier to just copy paste the existing code without having to worry about references that much. The disadvantage is lots of low value code bloating the code base. This can be mitigated by moving code to custom hooks which hides the bloat. Moreover, the rigorous use of memoization hooks is probably slightly worse because dependency checks require computing but this should be negligible.

An alternative approach is simply using these hooks only when necessary. This keeps the code base cleaner with less low value code. The downside is that developers need to know when references have to be stable and ensure that they stay that way. For example lots of time can be wasted by wondering why memoization doesn’t work at all when the problem is a reference changing every render. Or a nasty bug introduced by someone removing a useCallback which was not that useless after all. But all these issues can be mitigated by good development practices like code comments and code reviews.

Dependencies

There are multiple ways to break dependencies for a more stable handler memoization. The simplest way was using the state updater function which allows getting the state value without a dependency. However the function may not always be available and some extra work may be needed to keep it without side effects. A more universal solution is to store either the state value or the whole callback function in a mutable reference. This reference value can be used in the handler without a dependency.

Reducers allow breaking the logic of setting and adding the value. This means the handler no longer needs to know about the value. Reducers also allow using the dispatch function to remove the need of passing down handlers as props. Using reducers needs a bit more code so using them only makes sense if you benefit from their other advantages.

State handling

State updates are the major source of renders in React. With hooks these are caused by useState, useReducer and useSelector. Performance becomes an issue when a state updates too frequently and renders too many components.

Frequency of updates can often be improved by using the hooks properly. If a value is not needed for rendering then useRef can be used instead. When using useSelector, it’s better to return exactly what you need from the store and replace the default comparison function if needed.

Three ways can be used to reduce the amount of affected components. The state can be split and moved down to child components so that updates affect only one child. Sometimes the state can also be refactored to a separate sibling component. For example a state update at the top of the application will render the whole application. But in a separate component the state update becomes much less of a problem. The third way is to use component memoization with memo to prevent child components from rendering. For example if there are N items with useSelector then each state update runs these N selectors. If these items don’t update often then it might be better to use only one useSelector at parent component and memoize child components with memo.

One simple strategy is to store the state in Redux and implement very specific useSelector functions right where the data is needed. This means that the state only updates when needed, and the updates will only affect a small part of the application. Then component memoization with memo is only needed as the last resort. Reselect library can be further used to optimize selectors, especially if the logic becomes complicated.

Thanks for reading!

If you have any questions, comments or spot a mistake please send email to [email protected]