Fixing useContext Performance Issues

jherr

Jack Herrington

Posted on January 16, 2021

Fixing useContext Performance Issues

So you want to use useState and useContext as a state management solution, but every time the value of the context provider changes the entire tree gets re-rendered. You could use a library like Recoil, Valtio, Zustand, and Jotai to get around this problem, but you’d have to change how you store and access global state.

Is there a way to just fix the issues with useContext? Glad you asked! Yes there is! It’s react-tracked, a new library from Daishi Kato, who has been doing amazing work in the React state management space.

Banner image

Setting up Your useState/useContext Global Store

The first thing you need to do is set up your store properly. Daishi has some excellent documentation on this already, but let’s walk through the Typescript version of the store step by step.

First we create a store.tsx file and start that file with some React imports, as well as the structure of the store and a function that creates the useState hook.

import React, { createContext, useState, useContext } from 'react';

const initialState = {
  text1: "text1",
  text2: "hello",
};

const useMyState = () => useState(initialState);
Enter fullscreen mode Exit fullscreen mode

Our initial store is pretty simple, we have a couple of pieces of text, and we have a function that invokes the React useState hook with that initial state.

Why don’t we just call useState right there and cache the result? Because React hooks need to be called from within a React component so they can be bound to a component instance. Thus we need a function that will create the state when we need it.

The next step is to create the context:

const MyContext = createContext<ReturnType<typeof useMyState> | null>(null);
Enter fullscreen mode Exit fullscreen mode

This is a standard createContext call where the context will either hold null (at startup) or the return type from the useMyState call. Which will be the standard useState return of an array with the current value, and a setter function.

After that we need to create the SharedStateProvider React functional component:

const MyContext = createContext<ReturnType<typeof useMyState> | null>(null);

export const SharedStateProvider: React.FC = ({ children }) => (
   <MyContext.Provider value={useMyState()}>
      {children}
   </MyContext.Provider>
);
Enter fullscreen mode Exit fullscreen mode

This component goes at the top of the React tree and provides the context down to any child components that way to consume it. Notice that we are invoking useMyState at this time because we are in the context of the React component and it’s safe to do so.

And our final step is to create a custom hook that gets the state and the state setter:

export const useSharedState = () => {
   const value = useContext(MyContext);
   if (value === null)
     throw new Error('Please add SharedStateProvider');
   return value;
};
Enter fullscreen mode Exit fullscreen mode

This custom hook first uses useContext to get the context. It then checks to make sure it has that context and throws an error if it doesn’t. And then finally it returns the context, which would be the output of useState , so an array with a value and a setter.

Now our global store setup is done. No libraries. Just basic React with hooks and structured in a really clean way.

Using the Store

Now that we have our store defined we first import the SharedStateProvider and add it to our App like so:

import { SharedStateProvider } from "./store";

const App = () => (
  <SharedStateProvider>
     ...
  </SharedStateProvider>
);
Enter fullscreen mode Exit fullscreen mode

This will not only provide the context down to any component that wants to consume it, but also initialize the state to the value in initialState.

Finally we could add some components that use that state, like so:

import { useSharedState} from "./store";

const Input1 = () => {
  const [state, setState] = useSharedState();
  return (
    <input
      value={state.text1}
      onChange={(evt) =>
        setState({
          ...state,
          text1: evt.target.value,
        })
      }
    />
  );
};

const Text1 = () => {
  const [state] = useSharedState();
  return (
    <div>
      {state.text1}
      <br />
      {Math.random()}
    </div>
  );
};

const Text2 = () => {
  const [state] = useSharedState();
  return (
    <div>
      {state.text2}
      <br />
      {Math.random()}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now this code will work just fine. But you’ll notice that the Text2 component, which will never need to be updated because we have no way to update the text2 value it’s looking at will get updated any time the global state changes.

This is because React has no way to track what parts of the state the components are looking at. It doesn’t do that work for you, and that ends up being a performance problem when you have a lot of global state. Even the most minor change will end up re-rendering a bunch of components that don’t need re-rendering.

You can see that in this example because the random number on Text2 will keep changing when you type characters into Input1.

GIF showing errant re-rendering of the Text2 component

As you can see above, I’m not changing text2 and yet the component showing the text2 value is re-rendering.

React-Tracked to the Rescue

To fix this we bring in the 5Kb react-tracked library by adding it to our application:

npm install react-tracked
Enter fullscreen mode Exit fullscreen mode

And from there we go back to the store.tsx file and import the createContainer function from the library:

import { createContainer } from "react-tracked";
Enter fullscreen mode Exit fullscreen mode

We then remove the definitions for useSharedState and SharedStateProvider and add the following code:

export const {
  Provider: SharedStateProvider,
  useTracked: useSharedState,
} = createContainer(useMyState);
Enter fullscreen mode Exit fullscreen mode

The createContainer function takes the state creation function:

const useMyState = () => useState(initialState);
Enter fullscreen mode Exit fullscreen mode

And it then returns a Provider and a useTracked which are remapped on export to SharedStateProvider and useSharedState which is what the components are expecting.

The result is that an isolation where components only re-render if the data they are "tracking" is changed, this is shown below:

GIF showing only the Text1 component rendering when text1 is changed

Now when I change text1 only the Text1 component changes.

Not bad for just five 5Kb of additional code.

Conclusion

Daishi Kato’s react-tracked library is an easy way to take a well factored useState/useContext state management solution and make it performant by intelligently tracking which parts of the state are used by each component.

Video Version

Check out this Blue Collar Code Short Take on react-tracked if you want a video version of this article.


💖 💪 🙅 🚩
jherr
Jack Herrington

Posted on January 16, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Fixing useContext Performance Issues
bluecollarcoder Fixing useContext Performance Issues

January 16, 2021