Disclaimer, this guide was done for my latest involvement with a React app, thus the examples are based on React 17 and thus this article will not address async rendering, suspense, etc. Also, the docs linked are from the legacy documentation for React 17.

Problem Statement

React, despite being one of the most popular front-end libraries, doesn’t have primitives, nor in the framework, nor in the underlying language to ward off performance issues. This is why it’s important to understand how the React ecosystem works and how to optimize it.

DOM reconciliation optimization

React is a declarative library that allows you to describe the UI as a function of the state. The desired state of the UI is described in the render method of a component in a virtual DOM, an in memory representation of the desired state. React will handle reconciliation to update the real DOM with the changes when deemed necessary.

What is important to consider when writing components isn’t when React will update the DOM, this is a solved problem by the React team, but how costly the reconciliation process can be computationally expensive if one is careless:

Reconciliation of elements of different types - avoiding changing the type of elements

For elements of different types, say a div and a span, React will unmount the old element and mount the new one. This will lead to a full rebuild of the subtree of the element, which can be costly if the subtree is large. One should avoid changing the type of element if possible at the top of a component tree.

// Before
<div>
<Counter />
</div>

// After
<span>
<Counter />
</span>

The example above will destroy and re-create the Counter component.

Reconciliation of elements of same type - using the memo hook

For elements of the same type, React will look for differences in the element/component’s props then only update in the DOM what has changed, then do the same recursively for the element’s children. To avoid performance bottlenecks when comparing object props, React compares object props by reference and primitives by value!.

// Before
<div className="before" title="stuff" />

// After
<div className="after" title="stuff" />

The example above will only update the className prop of the div element, the same transformation will happen in the DOM only for the class-name attribute.

// Counter.tsx
const [value, setValue] = useState({ count: 0 });

<Counter value={value} />

// Update
setValue({ count: 0 });

The example above will update the Counter component, because the object reference of the prop has changed. We could say, well okay, what if we passed a primitive instead? Surely React would compare it and not update the component if the value hasn’t changed.

// App.tsx
const [value, setValue] = useState(0);

<Counter value={value} />

// Update
setValue(0);

The example above will update the Counter component, even though the value prop hasn’t changed, because the parent component (which holds the state) has re-rendered, and React will update the Counter component. But we can prevent this by using the memo hook.

Have a look at this codesanbox that illustrate those behaviors

Notice how the Object Ref with memo only re-renders once at the beginning, while the other components re-render every time you press the re-create value button.

Note that this is only true for the first level of props (shallow comparison), if the prop is an object, React will compare the object reference, and if the object reference has changed, React will update the component.

Lists optimization

When rendering lists, React will compare the list by reference, if the reference has changed, React will update the list. Using the memo hook on the list component will prevent the list items from updating if their props haven’t changed, which can be extremely useful when one has to update only a few items in a large list.

See the demo in codesandbox

Notice how only the parent component re-renders when the list is updated, and the list items only re-render when their props change for the use memo implementation.

Redux Toolkit optimization and common pitfalls

Redux Tookit allows one to write Redux logic in a more concise and efficient way. It also comes with ImmerJs, which allows one to write reducers in a more readable and less error-prone way.

Without ImmerJs, one would write the following code to update a deeply nested object:

function handleAction(state, action: Action<Substate>) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
third: action.payload
}
}
};
}

ImmerJs allows one to write the above code like this:

function handleAction(state, action: Action<Substate>) {
state.first.second.third = action.payload;
}

Which is way more readable and less error-prone for behavior. Yet Immer has its own pitfalls and tradeoff:

Overriding the entire slice

Because Redux Toolkit uses ImmerJs under the hood, the state of our slices is wrapped in a proxy object (called draft). This proxy object will be used by Immer to derive a new state without mutating the current state while presenting a mutable facade to the developer.

This has a computational cost when creating the proxy object (of the magnitude of several seconds if it is big enough!), which is one of the many reasons why we want to avoid updating the entire slice when only a sub-state needs to be updated.

Consider the following:

function setHightlightedPhrase(state, action: Action<PhrasesState>) {
state = action.payload;
}

As explained in the docs this will not work!. Here we only wanted to set the highlighted phrase, we ended up updating the entire slice and causing a re-render of all the phrases components. Plus the operation itself it will be slow if the action payload is large because of the proxy object wrapping.

This happened because ImmerJs will not compare deeply the new state with the old one. Assigning a new object to the state will merely change the reference of the state, and not the state itself. This is done for performance reasons, as it is costly to compare deeply nested objects.

As a general rule, ImmerJs WILL NEVER COMPARE DATA THAT IS ORIGINATING FROM OUTSIDE THE IMMER PROXY OBJECT WITH THE PROXY OBJECT ITSELF. Thus, only modify the leafs of the state, and not the state itself when applicable.

If one needs to reset, one needs to return a new object, like so:

function updateArticleState(state, action: Action<ArticleState>) {
return action.payload;
}

Which now no longer changes the reference of the state, but the state itself (recommended way by Redux Tookit docs).

Generally you should avoid updating the entire slice, and only update the sub-state that needs to be updated.

Correctly deriving data selectors

createSelector is a powerful, composable and memoized selector creator. It accepts a list of input selectors and a transform function (also called output selector), and will only recompute the output selector if one of the input selectors has changed (this is checked with strict equality operator: ===).

This means that if an input selector is creating a new object, the output selector will always recompute:

const selectArticle = createSelector(
[(state: RootState) => state.article.map(article => transformArticle(article))], // input selectors
article => article // output selector
);

In the example above, a new array is created as a result of the map operator, and thus the output selector will always recompute. Note that this why the previous example of updating the entire slice is bad, because it will always recompute the output selector of anything that depends on the slice.

To fix this, move the transformation outside the input selector into the output selector:

const selectArticle = createSelector(
[(state: RootState) => state.article],
article => article.map(article => transformArticle(article))
);

And now, the transformation will only happen if the article slice has changed. This is called memoization.

Shooting yourself in the foot with input parameters

createSelector can accept input parameters, which can be useful for creating memoized selectors that depend on a parameter, say a props or a state. It is done like so:

const selectArticle = createSelector(
[
(state: RootState) => state.article,
(_: RootState, articleId: string) => articleId,
],
(articles, articleId) => articles.find(article => article.id === articleId)
);

And used like so:

const [articleId, setArticleId] = useState('1');
const article = useSelector((state: RootState) => selectArticle(state, articleId));

This is all well and good, but one needs to be careful with the input parameters. The above example would behave as expected BUT if the input parameter is a new object or a new reference, the output selector will recompute. This is because, as stated before, the input selectors verify equality of what they yield with the === operator.

Now if you were to do this:

const articleIds = [1, 2, 3];

articleIds.map(articleId => {
const articleIdAsObject = { articleId };
const article = useSelector((state: RootState) => selectArticle(state, articleIdAsObject));
// render something
});

The output selector will always recompute, because articleIdAsObject will always be a new reference in the iteration thus the output selector will always recompute. Beware of input parameters coming from props, as they can be a new reference somewhere in the component tree.

If you really need to use an object as an input parameter, you can use the shallowEqual function from react-redux to compare the object references or, if, like me, you have a string primitive that react considered unchanged but redux didn’t, you can use createCachedSelector which will compare a custom cache key in place of the input selectors returned values.

const selectArticle = createCachedSelector(
[
(state: RootState) => state.article,
(_: RootState, articleId: string) => articleId,
],
(articles, articleId) => articles.find(article => article.id === articleId)
)(
(_: RootState, articleId: string) => articleId
);

Calling immediately the selector

DO NOT call a selector with the state like so:

export const selectForTranscriptionByLanguage = (language: string) => {/*[...]*/};

export const combineLanguagesSelector = (state: ProvidersLanguagesVoicesState) => {
return {
selectAvailableLanguagesPairForSuppliedLanguage: (language: string) => createSelector(
[selectForTranscriptionByLanguage(language)],
(pairs) => {/*[...]*/}
)(state)
/*[...]*/
}
}

This is bad because no memoization will happen, as a new selector will be created every time the combineLanguagesSelector is called.

Use selector composition instead with input parameters:

export const selectForTranscriptionByLanguage = (language: string) => {/*[...]*/};

export const selectAvailableLanguagesPairForSuppliedLanguage = createSelector(
[selectForTranscriptionByLanguage, (_: RootState, language: string) => language],
(pairs, language) => {/*[...]*/}
);

References for further reading on the subject

Transforming efficiently large arrays or objects

Avoid creating over and over objects:

Consider the following problem: You have 3’000’000 values normalized between zero and one and you want to aggregate them in buckets of 100 values, taking the mean of each bucket. One could do it like so:

const values = Array.from({length: 3000000}, () => Math.random());

const buckets = values.reduce((acc, value) => {
acc.currentBucket = [...acc.currentBucket, value];
if (acc.currentBucket.length === 100) {
acc.buckets.push(
acc.currentBucket.reduce((acc, value) => acc + value, 0) / 100
);
acc.currentBucket = [];
}
return acc;
},
{
currentBucket: [],
buckets: [],
});

Which is of correct behavior but inefficient because of the array spread operator, which creates a new array every time a value is added to the bucket. This is costly because the array has to be copied entirely every time a value is added. Plus you may run into garbage collection pauses if the array is large.

It is better to use a simple loop:

const values = Array.from({ length: 3000000 }, () => Math.random());

const buckets = [];
let currentSum = 0;
for (let i = 0; i < values.length; i++) {
currentSum += values[i];
if (i % 100 === 0) {
buckets.push(currentSum / 100);
currentSum = 0;
}
}

See the benchmark here. On my AMD Ryzen 9 5900X, the first solution took 680ms in median time to complete, while the second solution took 3.2ms in media time. That’s a 212x speedup!

Conclusion

Avoid updating the entire slice of a Redux state, only update the sub-state that needs to be updated. Use createSelector to create memoized selectors, and be careful with input parameters. Avoid creating new objects in selectors, and use createCachedSelector if you need to compare objects with a custom equality function. When transforming large arrays or objects, avoid creating new objects or arrays, and use loops instead of array methods.