Introduction
In the project I’m currently working on for a client (a global denim brand), my collegues and I started to notice performance degradation in our React app, which mostly consists of complex forms and different kinds of input components. Specifically, we’re working on the webshop checkout, a pretty crucial part in any webshop. Performance started to get so bad after a while that basic input fields couldn’t keep up with fast typing. This was annoying at first, but soon required action.
When we started looking into the cause of these performance issues, we found that most React components in the form would re-render whenever any form input value changed. We knew that this was caused by poor (un-optimized) handling of user events and data flow. Some of our components were hundreds of lines of JavaScript long - a red flag on it’s own - and had dozens of props that were being passed down multiple levels deep. This can impact performance as well, and debugging such components is no fun either. This is also often referred to as ‘prop-drilling’.
Digging a little deeper, we looked into our JavaScript performance using Chrome’s JS Profiler. Here we noticed that some frames would take up to 250-1000ms to render, when ideally you’d want that to be under 16.6ms for your browser to be able to render the app at 60fps.
When we measured how many times our Input
component would render during a normal user interaction flow, we saw that in some cases the component would re-render up to 800-1300 times, whoops!
Identifying the problem
We were using React Context for a lot of things - for retrieving values, accessing setter / getter functions and to keep track of the entire form state. While I do love the introduction of React Context and it’s ease of use, I suspected that - out of the box - things weren’t as optimized as one might assume. This started to become a problem as our app grew in complexity.
This might seem obvious, but if you notice that your machine is struggling with the app you’re building, it’s safe to assume that the experience for users on lower-end devices is going to be horrible. This especially becomes a problem when you’re working on a multi-language, globally used web app for example. As developers we’re usually working on fast Macbook Pro’s (or other up-to-date hardware), I’m using a 6-Core Intel Core i7 with 16GB of RAM for reference. It’s good to keep in mind, because performance in React apps doesn’t seem to become a problem that much these days.
I’ve worked with Redux and React Redux a lot in previous projects, and I know that while it does add some boilerplate and a few kb’s to your bundle, in my experience it’s always proven itself to be very reliable - and has matured nicely over the years 👌. Smart people have worked long and hard to optimize it’s performance so I feel comfortable using it in “mission-critical” situations.
Let’s dive into the approach we took to optimize the performance of our React application!
Our performance optimization todo’s
Before we started, we made a summary of things that we knew would improve overall performance. Not all of these improvements (such as using React.memo
) are covered in this post, but as you’ll see I’ve tried to cover the most important parts.
- Move business logic to container components and render presentational components
- Use
React.useCallback
for functions that are passed down to children - Use
React.memo
for components that receive many props but don’t need to re-render - Use
React.useMemo
to cache computationally heavy functions - Move a lot of re-used business logic to hooks
- Use
reselect
to memoize and compose Redux selectors - Use a Redux store with custom context instead of a ‘plain’ React Context
- Prevent re-renders by avoiding prop-drilling as much as possible
TL;DR - Results
We’ve measured a typical user flow / journey through the React checkout application, filling in a few forms, etc.
- 70-85% less component re-renders on average
- 80-97% less blocking time on average (in Profiler, as illustrated below)
- ~8% better Speed Index (in Lighthouse)
Improvements from a developers perspective:
- Seperation of concerns by using custom hooks and container / presentational components
- Cleaner and more maintainable codebase
- We discovered and fixed a handful of previously undetected bugs
- Interaction with checkout forms feels noticeably faster
Chrome’s JavaScript Profiler visualises this nicely. These screenshots show the before and after of a typical user flow, filling in the form in 20-30 seconds. Additionally, it shows the TBT (Total Blocking Time). TBT measures the total amount of time that a page is blocked from responding to user input, such as mouse clicks, screen taps, or keyboard presses.
TBT before: 12901ms (ouch)
TBT after: 388ms 😺
So, where do we start?
Your app currently looks something like this
You’ve got a React app with an existing global store (this is optional). I’ve included a global Redux store just to illustrate that we can use multiple stores next to each other. There’s a good chance that you, or your company or client uses Redux already anyway.
Here we’ll focus mainly on the components rendered inside <App />
, I don’t really care about the rest of the app, this might have been written by someone else, so preferably I don’t even need to touch it.
index.jsx
|
With a ‘global’ Redux store already in place
Quick example of a store that might exist inside your app already.
globalStore.js
|
But now we’ve got several instances of large / complex components in our app
In case of the project I’m working on we’re dealing with ‘dynamic’ forms which can be configured in our CMS. These forms contain a lot of logic (too much IMHO), but these are real-world scenarios we must deal with in larger codebases.
Here you can see that we render the App
component with some children, and when the app renders for the first time it dispatches an example action INIT_APP
to the ‘global’ Redux store. This is just to illustrate what your current app might look like.
Imagine that the three FormContainer
components contain lots of logic and different child components, their configuration is determined dynamically using the formId
value for example.
App.jsx
|
Let’s seperate logic with container and presentational components
Presentational components can be tested with ease. Simply mock the required props and you’re good to go.
You should be able to write these components without using the return
keyword, as in the following examples. I personally try to do no logic at all inside presentational components.
Form.jsx
|
FormInput.jsx
|
And we’ll use a React Context
Simply create an empty context, we will use this later on.
context.js
|
Now we’ll create the container components
This is where things start to get interesting.
The FormContainer
component is just a regular component where the business logic of the form is defined. This is the job of a container component. It uses a regular useDispatch
to dispatch actions to the ‘global’ store, and it uses some custom hooks that we’ll define later.
FormContainer.jsx
|
- Use the
connect
HOC (higher-order component) function to specify which context theFormContainer
component needs to connect to. - Pass the custom context and a Redux store as props to a default Redux
Provider
and wrap the connected component to create aFormContainerProvider
that provides the current store to any child components.
Now a FormInput
component can reliably retrieve values from the store of the form it’s currently rendered in, even though the component itself is used across different forms. 🤯
I wasn’t aware that you could pass in an options object as the fourth argument to the connect
function! We can simply use null
for the first three arguments because we don’t need to use mapStateToProps
, mapDispatchToProps
or mergeProps
. We want to get values using Redux selector hooks instead of mapping state variables to component props. This was common practice when we were working with class-based components, but nowadays we’d like to work with functions (hooks) only.
Now when we render three different instances of FormContainer
for example, three Redux stores will be created, each with the same initial state but unique internal state over time.
In theory you could pass some initial state as props to the FormContainer
component, which you would pass into the createFormStore
function if you want to set up the store with initial values based on props.
Getting form values and firing actions from a deeply nested component
It doesn’t matter how deep this component is nested inside the FormContainer
.
Calling setValue
will update a value in the current form store only, as described above.
No more hassle with mapStateToProps
or mapDispatchToProps
! 😁
FormInputContainer.jsx
|
Setting up some example complex logic for our form
We would need to validate input, write things to localStorage
- but not when it’s a password - etc.
utils.js
|
And this is where the magic happens
Hooks.
React Redux exposes three useful - but not very well documented - functions, which we can use to create custom Redux hooks.
createStoreHook
createDispatchHook
createSelectorHook
If we pass a context into these functions, we can create custom useDispatch
and useSelector
functions, that only operate on the ‘local’ / ‘sub’ store, the form store in our case.
In this post I’m creating a function useFormSelector
, but if you’re creating complex modals you could create useModalDispatch
and useModalSelector
for example - and use them if you have a context + store set up as shown above in FormContainer.jsx
.
hooks.js
|
The icing on the cake 🍰 reselect
Note, these selector functions can be used in both useSelector
and useFormSelector
!
I would recommend refactoring all existing selectors - even selectors for the ‘global’ store - to reselect-like selectors.
Organize and create your store value selectors in a central location to be re-used across multiple components. A great advantage of writing selectors like this with reselect
is that each selector is a ‘pure’ function. This makes sure your data is immutable and state becomes predictable. It also prevents any potential side effects.
This is a nice example of solving real-world problems using functional programming in JavaScript (and one of the reasons I like React and Redux in general). We can derive any new value from existing store values by composing selector functions using createSelector(...inputSelectors, resultFn)
. If the results from the input selectors don’t change, the selector won’t trigger a re-render in our component.
selectors.js
|
Combining selectors and using props or arguments
You can imagine that a form store contains many properties, some of which you want to combine to create new - more specific - values. Some components don’t need a subscription to the entire formConfigs
object in the following example snippet. It could be a large object that some components actually do need to subscribe to, but others might only need a subset of the data from that object.
Generally it’s a good idea to move as much logic to selectors when combining values from the store. The reducer function preferably shouldn’t do much logic or computationally expensive operations, as they will fire on every action of that type. Selectors are a nice way to abstract away store-related logic in a clean and reusable manner for consumers of store values. You could argue that it’s yet another Redux boilerplate file + necessary imports, but I think it’s better to write selectors once instead of re-declaring them in every component that needs that value.
Writing lots of selectors might seem stupid at first, but when selecting a value like state.cart.cart.consumer.billingAddress.street
in a few components, there’s a high chance to make a small mistake and write state.cart.consumer.billingAddress.street
instead. This is a bug I actually ran into while refactoring, it had gone unnoticed for almost 6 months.
Defining and having things in a centralised place prevents accidental typo’s and gives the benefit of being able to make a single change to update all affected components.
selectors.js
|
Optional: Going even further with re-reselect ♻️
Feel free to skip this part, these examples show more advanced usage of selectors. You probably don’t need this unless you’re dealing with a large, complex and performance intensive app. We chose not to use re-reselect
, so the results described in this article are based on reselect
selectors only.
|
The results
After the rewrite, which took approx. 2 weeks, we saw some nice results.
- We saw a decrease in re-renders of about 70-80%! Throughout the typical React app flow,
InputGroup
would re-render ~850 times, or more - which we reduced to ~150 times. - The code is cleaner, we fixed a small number of previously unnoticed bugs and components are small and just do their own job, rendering JSX.
- Most data manipulation logic happens very close to the store with ‘pure’ selector functions, instead of combining and transforming values from React Context in
useEffect
hooks. - We moved all (most) hooks to
hooks.js
files next to the components themselves. This allows components to simply import their own hooks, or other hooks from a parent component for example. - And we refactored all (but one) class-based components to functional components.
A good refactor once in a while can avoid a lot of ‘tech-debt’. This is especially true for projects where developers come and go. Also it revives motivation to work in the codebase which felt old and messy!
Conclusion
I hope these tricks can benefit you in your projects as much as it did in our case!
If you found this interesting, consider following me on Github. I’m very much open to questions/feedback, don’t hesitate to send a message or leave a reply. 🙂
A Passionate People collegue of mine - “The Spider” 🕷 - made a video on how to tackle the problem of component re-renders without Redux using the event pattern. Might be worth a watch if you don’t want to / can not use Redux in your app! Youtube link.
Links and references
- Using custom context hooks
- Providing custom context with React Redux
- Redux’s reselect library (!)
- Create cached selectors with re-reselect