21 August 2019

cover

4 options to prevent extra rerenders with React context

How do you like react-tracked
Share on:

Introduction

React context and useContext are very handy. You would have no problem using it while developing a small app. If the size of your app became non-trivial, you might experience some performance issues with regard to useContext. This is because useContext will trigger rerender whenever the context value is changed. This happens even if the part of the value is not used in render. This is by design. If useContext were to conditionally trigger rerenders, the hook would become non-composable.

There has been several discussions, especially in this issue. Currently, there’s no direct solution from React core. Three options are described in this issue.

This post shows an example with these three options and another option with a library called react-tracked.

Base example

Let’s have a minimal example: A person object with firstName and familyName.

const initialState = {
  firstName: 'Harry',
  familyName: 'Potter',
};

We define a reducer to feed into useReducer.

const reducer = (state, action) => {
  switch (action.type) {
    case 'setFirstName':
      return { ...state, firstName: action.firstName };
    case 'setFamilyName':
      return { ...state, familyName: action.familyName };
    default:
      throw new Error('unexpected action type');
  }
};

Our context provider looks like this.

const NaiveContext = () => {
  const value = useReducer(reducer, initialState);
  return (
    <PersonContext.Provider value={value}>
      <PersonFirstName />
      <PersonFamilyName />
    </PersonContext.Provider>
  );
};

PersonFirstName is implemented like this.

const PersonFirstName = () => {
  const [state, dispatch] = useContext(PersonContext);
  return (
    <div>
      First Name:
      <input
        value={state.firstName}
        onChange={(event) => {
          dispatch({ type: 'setFirstName', firstName: event.target.value });
        }}
      />
    </div>
  );
};

Similar to this, PersonFamilyName is implemented.

So, what would happen is if familyName is changed, PersonFirstName will rerender resulting in the same output as before. Because users wouldn’t notice the change, this wouldn’t be a big problem. But, it may slow down when the number of components to rerender is large.

Now, how to solve this? Here are 4 options.

Option 1: Split contexts

The most preferable option is to split contexts. In our example, it will be like this.

const initialState1 = {
  firstName: 'Harry',
};

const initialState2 = {
  familyName: 'Potter',
};

We define two reducers and use two contexts. If this makes sense in your app, it’s always recommended in idiomatic React. But if you need to keep them in a single state, you can’t take this option. Our example is probably so, because it’s meant to be a single person object.

Option 2: React.memo

The second option is to use React.memo. I think this is also idiomatic.

We don’t change the context in the base example. PersonFirstName is re-implmented with two components.

const InnerPersonFirstName = React.memo(({ firstName, dispatch }) => (
  <div>
    First Name:
    <input
      value={firstName}
      onChange={(event) => {
        dispatch({ type: 'setFirstName', firstName: event.target.value });
      }}
    />
  </div>
);

const PersonFirstName = () => {
  const [state, dispatch] = useContext(PersonContext);
  return <InnerPersonFirstName firstName={state.firstName} dispatch={dispatch} />;
};

When familyName in the person object is changed, PersonFirstName rerenders. But, InnerPersonFirstName doesn’t rerender because firstName isn’t changed.

All complex logic is moved into InnerPersonFirstName and PersonFirstName is typically lightweight. So, performance wouldn’t be an issue with this pattern.

Option 3: useMemo

If React.memo doesn’t work as you expect, you can useMemo as the third option. I wouldn’t personally recommend this. There might be some limitations. For example, you can’t use hooks.

PersonFirstName looks like this with useMemo.

const PersonFirstName = () => {
  const [state, dispatch] = useContext(PersonContext);
  const { firstName } = state;
  return useMemo(() => {
    return (
      <div>
        First Name:
        <input
          value={firstName}
          onChange={(event) => {
            dispatch({ type: 'setFirstName', firstName: event.target.value });
          }}
        />
      </div>
    );
  }, [firstName, dispatch]);
};

Option 4: react-tracked

The fourth option is to use a library.

https://github.com/dai-shi/react-tracked

With this library, our provider would look a bit different like this.

const { Provider, useTracked } = createContainer(() => useReducer(reducer, initialState));

const ReactTracked = () => {
  return (
    <Provider>
      <PersonFirstName />
      <PersonFamilyName />
    </Provider>
  );
};

PersonFirstName is implemented like this.

const PersonFirstName = () => {
  const [state, dispatch] = useTracked();
  return (
    <div>
      First Name:
      <input
        value={state.firstName}
        onChange={(event) => {
          dispatch({ type: 'setFirstName', firstName: event.target.value });
        }}
      />
    </div>
  );
};

Notice the change from the base example. It’s just one line change.

-  const [state, dispatch] = useContext(PersonContext);
+  const [state, dispatch] = useTracked();

How does this work? The state returned by useTracked() is wrapped by Proxy, and its usage is tracked. It means that the hook knows that only the firstName property is used in render. This allows to only trigger rerender when used properties are changed. This effortless optimization is what I call “state usage tracking.”

What is state usage tracking

For more information, please visit my other blog posts. For example:

What is state usage tracking? A novel approach to intuitive and performant global state with React hooks and Proxy

There’s also a list of blog posts.

Full example demo

Source code in the repo

Closing notes

If you have already read some previous blog posts of mine, there can be no new findings in this post.

I would like to learn more coding patterns from others. Please let me know how it would look like in your use case.

comments powered by Disqus