How we reduced our re-renders by 80% with React Redux and custom context

2021-01-29

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.

  1. Move business logic to container components and render presentational components
  2. Use React.useCallback for functions that are passed down to children
  3. Use React.memo for components that receive many props but don’t need to re-render
  4. Use React.useMemo to cache computationally heavy functions
  5. Move a lot of re-used business logic to hooks
  6. Use reselect to memoize and compose Redux selectors
  7. Use a Redux store with custom context instead of a ‘plain’ React Context
  8. 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 😺


React performance optimizations Profiler results

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

import React from "react"
import { render } from "react-dom"
import { Provider } from "react-redux"

import store from "./globalStore"

import App from "./App"

render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root"),
)

With a ‘global’ Redux store already in place

Quick example of a store that might exist inside your app already.

globalStore.js

import { createStore } from "redux"

/**
* Define and export action types.
*/
export const INIT_APP = "INIT_APP"
export const SOME_GLOBAL_UPDATE_FORM_ACTION = "SOME_GLOBAL_UPDATE_FORM_ACTION"
export const SOME_GLOBAL_RESET_FORM_ACTION = "SOME_GLOBAL_RESET_FORM_ACTION"

/**
* Example 'global' Redux store
* that might exist in your app already.
*/
const initialState = {
initialized: false,
forms: {},
someUserData: {},
// ... etc
}

/**
* A standard reducer function.
*
* @param {object} state
* @param {object} action
* @returns {object} nextState
*/
const reducer = (state = initialState, { type, payload }) => {
switch (type) {
case INIT_APP:
return {
...state,
initialized: true,
}
case SOME_GLOBAL_UPDATE_FORM_ACTION:
return {
...state,
forms: { ...state.forms, [payload.formId]: payload.values },
}
case SOME_GLOBAL_RESET_FORM_ACTION:
return {
...state,
forms: Object.fromEntries(
Object.entries(state.forms).filter(([key]) => key !== payload.formId),
),
}
// ... etc
default:
return state
}
}

export default createStore(reducer)

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

import React, { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux"

import { FormContainer } from "./FormContainer"

/**
* App component with some example form components.
*
* @returns {React.FC}
*/
const App = () => {
const dispatch = useDispatch()
const isInitialized = useSelector((state) => state.initialized)

useEffect(() => {
if (!isInitialized) dispatch({ type: "INIT_APP" })
}, [dispatch, isInitialized])

return (
<div className="App">
<h1>React + Redux app</h1>
<hr />
<FormContainer formId="homeBillingForm" />
<FormContainer formId="homeShippingForm" />
<FormContainer formId="guestLoginForm" />
</div>
)
}

export default App

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

import React from "react"

/**
* Form presentational component.
*
* @returns {React.FC}
*/
const Form = ({ children, ...props }) => <form {...props}>{children}</form>

export default Form

FormInput.jsx

import React from "react"

/**
* Example input presentational component.
*
* @returns {React.FC}
*/
const FormInput = ({ children, ...props }) => (
<div className="FormInput">
<input type="text" {...props} />
{children}
</div>
)

export default FormInput

And we’ll use a React Context

Simply create an empty context, we will use this later on.

context.js

import { createContext } from "react"

const FormContext = createContext(null)

export default FormContext

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

import React, { useCallback, useEffect, useRef } from "react"
import { Provider, useDispatch } from "react-redux"

import FormContext from "./context"
import { SOME_GLOBAL_UPDATE_FORM_ACTION } from "./globalStore"
import { createFormStore, useFormActions, useFormSelector } from "./hooks"
import { valuesSelector, requiredFieldsFilledSelector } from "./selectors"

import Form from "./Form"

/**
* Form container component.
* In this component we will handle business logic.
*
* @returns {React.FC}
*/
const FormContainer = ({ formId, children, ...props }) => {
/**
* We're still free to use the 'global' dispatch function wherever we like.
*/
const dispatch = useDispatch()

/**
* Get a function from a custom hook which we'll write later on
*/
const { setFormId } = useFormActions()

/**
* Get values from the form store using our
* custom Redux hook 'useFormSelector'.
*/
const values = useFormSelector(valuesSelector)
const requiredFieldsFilled = useFormSelector(requiredFieldsFilledSelector)

useEffect(() => {
setFormId(formId) // Just to demonstrate how to dispatch to the form store
}, [setFormId, formId])

const onSubmitHandler = useCallback(
(e) => {
e.preventDefault()

if (requiredFieldsFilled) {
dispatch({
type: SOME_GLOBAL_UPDATE_FORM_ACTION,
payload: { formId, values },
})
}
},
[dispatch, requiredFieldsFilled, formId, values],
)

return (
<Form onSubmit={onSubmitHandler} {...props}>
{children}
</Form>
)
}

/**
* Connected form container component.
* Connects the component to the form context.
* This allow the use of custom Redux hooks.
* https://react-redux.js.org/next/api/hooks#custom-context
*/
const ConnectedFormContainer = connect(null, null, null, {
context: FormContext,
})(FormContainer)

/**
* Form container provider.
* Provides a Redux store for each form instance and renders the connected form container.
* https://react-redux.js.org/using-react-redux/accessing-store#providing-custom-context
*
* You can provide an optional parameter 'store' which can be useful when writing tests.
* (not covered in this blog post)
*
* @returns {React.FC}
*/
const FormContainerProvider = ({ store, children, ...props }) => {
/**
* Create a new store for every instance of FormContainerProvider.
* 'useMemo' makes sure we create the store only once before the component will mount.
* 'useRef' makes sure we get a consistent reference to the store object.
*/
const formStore = useMemo(() => store || createFormStore(), [])
const { current } = useRef(formStore)

return (
<Provider context={FormContext} store={current}>
<ConnectedFormContainer {...props}>{children}</ConnectedFormContainer>
</Provider>
)
}

/**
* Export the form container provider instead of the form container component.
*/
export { FormContainerProvider as default }
  • Use the connect HOC (higher-order component) function to specify which context the FormContainer 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 a FormContainerProvider 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

import React, { useMemo } from "react"

import { formIdSelector, createFormConfigSelector } from "./hooks"
import { useFormActions } from "./hooks"

import FormInput from "./FormInput"

/**
* Form input container component.
*
* @returns {React.FC}
*/
const FormInputContainer = ({ name, children, ...props }) => {
/**
* ❌ creates a subscription and triggers a re-render on all updates to FormContext.
*/
// const { someValue } = useContext(FormContext)

/**
* ⚠️ creates a subscription to a single store value.
*/
// const someValue = useFormSelector(state => state.someValue)

/**
* ✅ creates a memoized subscription to a single store value.
*/
// const someValue = useFormSelector(someValueSelector)

// Example usage, using reselect-like selectors:
const formId = useFormSelector(formIdSelector)

// We may want to use a prop or other variable to select something from the store
// Create the selector function once with useMemo
const formConfigSelector = useMemo(createFormConfigSelector, [])
const formConfig = useFormSelector((state) =>
formConfigSelector(state, formId),
)
console.log(formConfig)

const { setValue } = useFormActions()

const onChangeHandler = useCallback((e) => setValue(name, e.target.value), [
setValue,
name,
])

return (
<FormInput onChange={onChangeHandler} {...props}>
{children}
</FormInput>
)
}

export default FormInputContainer

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

/**
* Just a single example of all the utility functions
* that we would use in our complex form.
*
* @param {string} value An input value
* @param {object} validationRule A dynamic validation rule
* @returns {boolean} isValid
*/
export const validateInput = (value, validationRule) => {
if ((value === undefined || value === "") && validationRule?.isRequired) {
return false
}

if (value && value?.length > validationRule?.maxLength) {
return false
}

return true
}

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

import { useCallback } from "react"
import { createStore } from "redux"
import {
createStoreHook,
createDispatchHook,
createSelectorHook,
useDispatch,
} from "react-redux"

import FormContext from "./context"
import { SOME_GLOBAL_RESET_FORM_ACTION } from "./globalStore"
import { formIdSelector, validationRulesSelector } from "./selectors"
import { validateInput } from "./utils"

/**
* Form reducer constants.
*/
const SET_FORM_ID = "SET_FORM_ID"
const SET_VALUE = "SET_VALUE"
const SET_VALIDATION_RULES = "SET_VALIDATION_RULES"
const RESET_FORM_STATE = "RESET_FORM_STATE"

/**
* Form reducer initial state.
*/
const initialState = {
formId: undefined,
values: {},
validityValues: {},
validationRules: {},
}

/**
* Another standard reducer function.
* This reducer will only handle form actions.
*
* @param {object} state
* @param {object} action
* @returns {object} nextState
*/
const reducer = (state = initialState, { type, payload }) => {
switch (type) {
case SET_FORM_ID:
return { ...state, formId: payload }
case SET_VALUE:
return {
...state,
values: {
...state.values,
[payload.key]: payload.value,
},
validityValues: {
...state.validityValues,
[payload.key]: payload.isValid,
}
}
case SET_VALIDATION_RULES:
return {
...state,
validationRules: { ...state.validationRules, ...payload },
}
case RESET_FORM_STATE:
return { ...initialState }
default:
return state
}
}

/**
* Redux form store factory function.
*
* Note that 'preloadedState' is not used in this example.
* It's useful when you want to provide a custom initial state
* when writing tests for example.
*
* @param {object} preloadedState Optional, default: undefined
* @returns {object} store
*/
export const createFormStore = (preloadedState) =>
createStore(reducer, { ...initialState, ...preloadedState })

/**
* Rarely used hook for retrieving the form store directly.
* Preferably, use useFormSelector to access store values.
*/
export const useFormStore = createStoreHook(FormContext)

/**
* Form dispatch hook, similar to react-redux's useDispatch hook.
* Actions dispatched using this hook will only affect the specified context.
*/
export const useFormDispatch = createDispatchHook(FormContext)

/**
* Form selector hook, similar to react-redux's useSelector.
* Use this hook to retrieve data from the form store.
*/
export const useFormSelector = createSelectorHook(FormContext)

/**
* Hook for convenient access to the form Redux actions.
*
* @returns {object} formActions
*/
export const useFormActions = () => {
/**
* Use useDispatch and useFormDispatch to be able to
* dispatch actions to both the form store and the global store.
*/
const dispatch = useDispatch()
const formDispatch = useFormDispatch()

/**
* Get (aka select) some values from the form store with 'useFormSelector'.
* It's no problem to use these hooks inside other hooks like this.
*/
const formId = useFormSelector(formIdSelector)
const validationRules = useFormSelector(validationRulesSelector)

/**
* Sets the form id.
*
* @param {string} id
*/
const setFormId = useCallback(
(id) => formDispatch({ type: SET_FORM_ID, payload: id }),
[formDispatch],
)

/**
* Sets a form value and does a validation check.
* We keep track of the value's validity using the 'validityValues' object.
*
* @param {string} key
* @param {string} value
*/
const setValue = useCallback(
(key, value) => {
if (value === undefined) return

const isValid = validateInput(value, validationRules?.[key])

formDispatch({
type: SET_VALUE,
payload: { key, value, isValid },
})
}
[formDispatch, validateInput, validationRules],
)

/**
* Sets the validation rules.
*
* @param {object} validationRules
*/
const setValidationRules = useCallback(
(validationRules) =>
formDispatch({ type: SET_VALIDATION_RULES, payload: validationRules }),
[formDispatch],
)

/**
* Reset the entire form state in the current context.
* And - as an example - also update the global Redux store.
*/
const resetFormValues = useCallback(() => {
formDispatch({ type: RESET_FORM_STATE })
dispatch({ type: SOME_GLOBAL_RESET_FORM_ACTION, payload: formId })
}, [formDispatch, dispatch, formId])

return {
setFormId,
setValue,
setValidationRules,
resetFormValues,
}
}

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

import { createSelector, createSelectorCreator, defaultMemoize } from "reselect"
import isEqual from "react-fast-compare"

/**
* Creates a custom selector creator function.
* The resulting selector performs a deep equality comparison.
* Uses the 'isEqual' function from 'react-fast-compare'
* Useful for memoization of values of type object or array.
*
* The default 'createSelector' performs strict reference equality comparison (with '===').
*/
export const createDeepEqualSelector = createSelectorCreator(
defaultMemoize,
isEqual,
)

/**
* Composable memoized Redux selector functions.
* Syntax: createSelector|createDeepEqualSelector(...inputSelectors, resultFn)
* https://github.com/reduxjs/reselect
*
* Each selector must be a 'pure' function.
* A benefit of this is that it makes selectors very reliable and easily testable.
*/
export const formIdSelector = createSelector(
(state) => state?.formId,
(formId) => formId,
)

export const valuesSelector = createDeepEqualSelector(
(state) => state?.values || {},
(values) => values,
)

export const validationRulesSelector = createDeepEqualSelector(
(state) => state?.validationRules || {},
(validationRules) => validationRules,
)

export const validityValuesSelector = createDeepEqualSelector(
(state) => state?.validityValues || {},
(validityValues) => validityValues,
)

/**
* Before, we would calculate a value like this inside (multiple) components.
* In this new approach we can move the computation to a re-usable selector, like this:
*/
export const requiredFieldsFilledSelector = createSelector(
valuesSelector,
validationRulesSelector,
validityValuesSelector,
(values, validationRules, validityValues) =>
Object.keys(values)
.filter((key) => validationRules?.[key]?.required)
.every((key) => validityValues?.[key] === true),
)

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

// imports, other selectors, etc...

export const formConfigsSelector = createDeepEqualSelector(
(state) => state?.formConfigs || {},
(formConfigs) => formConfigs,
)

export const useShippingAsBillingSelector = createSelector(
(state) => state?.useShippingAsBilling,
(useShippingAsBilling) => useShippingAsBilling,
)

/**
* Example of a selector creator function.
* (returns a selector which you can pass an argument)
*/
export const createFormConfigSelector = () =>
createDeepEqualSelector(
formConfigsSelector,
(_, formId) => formId,
(formConfigs, formId) => formConfigs?.[formId] || {},
)

/**
* Selecting a child property from the store
*/
export const formConfigShippingSelector = createDeepEqualSelector(
formConfigsSelector,
(formConfigs) => formConfigs?.shipping,
)

/**
* This selector selects a value that has slightly different behavior
* as is often the case in more complex apps.
*/
export const formConfigBillingSelector = createDeepEqualSelector(
formConfigsSelector,
useShippingAsBillingSelector,
(formConfigs, useShippingAsBilling) =>
useShippingAsBilling ? {} : formConfigs?.billing,
)

export const combinedFormConfigSelector = createDeepEqualSelector(
formConfigShippingSelector,
formConfigBillingSelector,
(formConfigShipping, formConfigBilling) => ({
...formConfigShipping,
...formConfigBilling,
}),
)

/**
* Or:
*/
export const combinedFormConfigSelector = createDeepEqualSelector(
formConfigShippingSelector,
formConfigBillingSelector,
(...args) => Object.assign({}, ...args),
)

/**
* Transforming the selection even further:
*/
export const combinedFormConfigValuesSelector = createDeepEqualSelector(
combinedFormConfigSelector,
Object.values,
)

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.

import { createCachedSelector, LruObjectCache } from "re-reselect"

/**
* This creates a selector that caches based on the 'prefix' value.
* So whenever this selector is called with a previously used prefix it will return the
* previously computed selection, instead of re-evaluating the example 'formatValues' function.
* The function determining which value the selector should cache on is referred to as 'keySelector'.
*
* Note that these variable names from the store are totally random and
* have nothing to do with the examples listed above.
*/
const formattedFormValuesSelector = createCachedSelector(
(state) => state.values,
(state, format) => format,
(state, format, prefix) => prefix,
(values, format, prefix) => formatValues(values, format, prefix),
)((state, format, prefix) => prefix) // Cache selectors by prefix

/**
* This is exactly the same.
*/
const formattedFormValuesSelector = createCachedSelector(
(state) => state.values, // a
(state, format) => format, // b
(state, format, prefix) => prefix, // c
(a, b, c) => formatValues(a, b, c), // resultFn
)((state, format, prefix) => prefix) // keySelector

/**
* Or:
*/
const formattedFormValuesSelector = createCachedSelector(
(state) => state.values,
(state, format) => format,
(state, format, prefix) => prefix,
formatValues, // It's just functional programming!
)((state, format, prefix) => prefix) // Cache selectors by prefix

/**
* Customizing the caching implementation by specifying a custom cache object
* https://github.com/toomuchdesign/re-reselect/tree/master/src/cache#readme
*/
const formattedFormValuesSelector = createCachedSelector(
(state) => state.values,
(state, format) => format,
(state, format, prefix) => prefix,
formatValues,
)({
keySelector: (state, format, prefix) => prefix, // Cache selectors by prefix
cacheObject: new LruObjectCache({ cacheSize: 5 }), // Use the 'LruObjectCache' to cache a maximum of 5 objects
})

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.



Comments: