How I ruined my application performances by using React context instead of Redux
Georges Biaux7 min read
TL;DR
- I used React contexts instead of Redux for centralized states
- Without a selector system, my components where getting lots of data as props, some of them were often changing and not necessary to build the view
- Any changes in these contexts objects caused almost all my components to rerender
- I had thousands of useless rerenders at every user interaction
- Refactor all the application to use Redux and use wisely the selector system to give each components strictly what it needed solved the problem
- Before choosing between contexts or redux, think about the optimizations the selector system can bring you
A bit of context
At Theodo, we heavily use Scrum, which is a great way to manage a backlog sprint after sprint. But many of our projects had hard times maintaining a clear and evolutive release plan. We especially had problems making visible the dependencies each of our EPICs had with external teams or services.
So, we decided to develop a tool to address those problems: Splane.
A typical Splane board
As you can see, this is a trello-like Kanban board where each column represents a sprint. The idea is that you have two distinct zones: the top one to organise your EPICs and the bottom one to manage your dependencies.
I chose React to develop the frontend part, but I made a big architectural mistake at the very beginning of the project.
Why I chose to get rid of Redux and how I replaced it
We’ve been using Redux on all our React projects for a long time, and Redux is part of our React boilerplate. But I’ve always found that Redux was a pretty heavy and verbose system with all its reducers, action creators and selectors. And, on top of that, at that time, our boilerplate included Flow typing (we’ve moved to Typescript since), which made the whole thing even more verbose.
So, when the application needed its first centralized state, I told myself “Let’s make it much simpler, let’s use React context instead of Redux”.
To make this new context based architecture easy to use, I developed the following HOC:
import React, { useContext, useState } from 'react';
import { getDisplayName } from 'recompose';
export const provideContext = (
Context, // The context object the state will be saved in
name, // The state name
setterMethodName, // The state setter name
defaultValue = null, // The state default value
) => WrappedComponent => {
const ComponentWithContext = props => {
const [state, setState] = useState({
[name]: typeof defaultValue === 'function' ? defaultValue(props) : defaultValue,
[setterMethodName]: setContext,
});
function setContext(value) {
setState({
...state,
[name]: value,
});
}
return (
<Context.Provider value={state}>
<WrappedComponent {...{ ...props, ...state }} />
</Context.Provider>
);
};
ComponentWithContext.displayName = `provideContext(${getDisplayName(WrappedComponent)})`;
return ComponentWithContext;
};
export const withContext = Context => WrappedComponent => {
const ComponentWithContext = props => {
const context = useContext(Context);
return <WrappedComponent {...{ ...props, ...context }} />;
};
ComponentWithContext.displayName = `withContext(${getDisplayName(WrappedComponent)})`;
return ComponentWithContext;
};
export default withContext;
As you can see, all I had to do after that was to wrap a parent component with provideContext
and then inject the state and the state setter to my children components with withContext
:
// MyParentComponent.js
const MyParentComponent = () => (<div>
<MyChildComponent />
</div>)
export default provideContext(CurrentUserContext, 'currentUser', 'setCurrentUser', { username: 'Obi-Wan Kenobi' })(MyParentComponent);
// MyChildComponent.js
const MyChildComponent = ({ currentUser, setCurrentUser }) => (<div>
<span className="username">{currentUser.username}</span>
<button onClick={() => setCurrentUser({ ...currentUser, username: 'Yoda' })}>Become Yoda</button>
</div>)
export default withContext(CurrentUserContext)(MyChildComponent);
Not perfect, but it was simpler than Redux. During the first few months of the project, I was quite happy with this system.
Why it was a mistake
The application grew, as most of them do. And the performances degraded slowly. At one point, we wanted to develop a feature so the user could link an EPIC to its dependencies, and when he hovered the EPIC card, the link would appear. So, we developed the feature using the context system, and this happened:
The performance issue
The application was incredibly slow. After a few investigations, we found out (partly thanks to why-did-you-update) that each user interaction caused thousands of useless rerenders. Every time the user hovered an EPIC card, almost every other EPICs and dependencies were rerendering several times.
So, what caused this huge amount of rerenders ?
To display the link between an EPIC and its dependencies, I created a context to store (for instance) the current hovered card, and I put React.memo HOC on all my components. So in my EPICs and dependencies component, I had something like this:
// Board.js
export default React.memo(provideContext(CurrentCardContext, 'currentCard', 'setCurrentCard', null)(Board)); // Board is the parent component of all the EPICs and dependencies
// Epic.js
const Epic = (epic, currentCard, setCurrentCard) => (
<div
className={epic.id === currentCard.id ? 'hover' : ''}
onMouseOver={() => {
setCurrentCard({
id: epic.id,
type: EPIC_CARD_TYPE,
});
}}
onMouseOut={() => {
setCurrentCard(null);
}}
/>
);
export default React.memo(withContext(CurrentCardContext)(Epic));
Of course, this example is a huge simplification of my actual component and contexts. In fact, I injected 5 different contexts inside the epic component and I did other operation in onMouseOver
and onMouseOut
.
But, this simple example shows the problem: each time I hover an EPIC or a dependency, ALL the EPICs and the dependencies rerender because the currentCard
value changes and ALL the EPICs and dependencies take it as a prop. This leads to hundreds of useless rerenders when, in fact, I could only rerender two cards (the previous hovered card and the current hovered card). I let you imagine what this can lead to when you have a huge board and the current card data is not the only one provoking useless rerenders. The performances were mediocre.
What I had to do to fix everything
Well, I needed a selector system. Why ? To inject into the components only the data they need, and move the computation/transformation of such data outside of the rendering cycle. Basically, my choices were to code a selector system on top of my contexts, hence recoding a big part of Redux, or use Redux itself. Of course, I chose Redux and did this:
// Epic.js
const Epic = (epic, isHovered, setCurrentCard) => (
<div
className={isHovered ? 'hover' : ''}
onMouseOver={() => {
setCurrentCard({
id: epic.id,
type: EPIC_CARD_TYPE,
});
}}
onMouseOut={() => {
setCurrentCard(null);
}}
/>
);
const mapStateToProps = (state, props) => ({
isHovered: state.currentCard && state.currentCard.id === props.epic.id,
});
const mapDispatchToProps = dispatch => ({
setCurrentCard: currentCard => dispatch(currentCardActions.setCurrentCard({ currentCard })),
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Epic);
Here, the computation of isHovered is not done during the render, but in the Redux lifecycle, which is less costly. And with this trick, when hovering an EPIC or a dependency, only this card and the previously hovered one will rerender. By applying this principle to all the centralized states everywhere in the application, the time it took to display the link between two card dropped from 1500ms to 100ms.
Conclusion
Yes, the title is a clickbait. Contexts are not bad, and Redux should not be used whenever you need a centralized state. But before choosing one of them, think about the optimizations the selector system and the Redux lifecycle can bring you. In my opinion, contexts should be used for simple data that do not change often, and when it gets more complicated than that, you should go for Redux.
One last thing
If you’d like to try Splane, feel free to do so. It’s free and open to everyone ;)
Questions about this subject? Want to know more? Feel free to contact our React experts.