55

How I ruined my application performances by using React context instead of Redux

 4 years ago
source link: https://www.tuicool.com/articles/Yfe2Urj
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 .

iQzIJ3I.png!web 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:

iUV3iib.gif 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. So in my EPICs and dependencies component, I had something like this:

// Board.js
export default 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 withContext(CurrentCardContext)(Epic);

Of course, this example is a huge simplification of my actual component. 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 :wink:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK