React context, performance?

romaintrotard

Romain Trotard

Posted on November 10, 2021

React context, performance?

Today we are going to talk about React context. Its role is sometimes mistaken, badly said as a mini-redux. Firstly we are going to see what it is, then talk about performance and workarounds we have.

What is it?

I can't have a better definition than in the documentation:

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Conceptually, you will put data in a React context and provides it to a React sub-tree component thanks to a Provider. Then in all components in this sub-tree, you are able to get the data thanks to a Consumer. At every changes of the data in the context, each consumers will be notified.

So there is no notion of state management here, so don't be confused, React context is not a mini-redux. But you can simulate it, if you combine it with state or reducer. However, you have to be aware that redux supplies some features like:

  • time travelling
  • middlewares
  • performance optimizations

How to use React context

Creation of a context

The creation is made thanks to the createContext method pulls from React. This method takes as only parameter the default value that is optional:

const MyContext = React.createContext();
Enter fullscreen mode Exit fullscreen mode

Provider

The Provider is accessible through the created context:

const MyProvider = MyContext.Provider;
Enter fullscreen mode Exit fullscreen mode

The Provider obtained is a Component has the following prop:

  • a value: the value you want to provide to children components
  • children: the children to which you want to provide the value
<MyProvider value={valueToProvide}>
  {children}
</MyProvider>
Enter fullscreen mode Exit fullscreen mode

Note: Make a Provider component that takes children instead of putting directly components in the Provider like this:

function App() {
  const [data, setData] = useState(null);

  return (
    <MyContext.Provider value={{ data, setData }}>
      <Panel>
        <Title />
        <Content />
      </Panel>
    </MyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Doing like this each time the setData is called, it will render all components Title, Content and Panel even if they do not use the data.

So instead do:

function MyProvider({ children }) {
  const [data, setData] = useState(null);

  return (
    <MyContext.Provider value={{ data, setData }}>
      {children}
    </MyContext.Provider>
  );
}

function App() {
  return (
    <MyProvider>
      <Panel>
        <Title />
        <Content />
      </Panel>
    </MyProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Consumer

Once we provide some data, we probably want to get it somewhere in a child. There is 2 ways to get it:

  • with useContext hook
  • with the Consumer component provided by the context we created

useContext

It's the hook to consume value from the context. You just have to pass the context to the hook:

const myValue = useContext(MyContext);
Enter fullscreen mode Exit fullscreen mode

Note: You may want to create a custom hook useMyContext not to export the context directly. It will allow you to make some check to ensure that the Provider has well been added:

const useMyContext = () => {
  const value = useContext(MyContext);

  if (!value) {
    throw new Error(
      "You have to add the Provider to make it work"
    );
  }

  return value;
};
Enter fullscreen mode Exit fullscreen mode

Warning: The check will not work if you put a default value in the context.

Consumer component

As said previously, the created context exports a Consumer component too (like Provider), you can then get the value by passing a function as children:

<MyContext.Consumer>
  {(value) => {
    // Render stuff
  }
</MyContext.Consumer>
Enter fullscreen mode Exit fullscreen mode

Recommendation and property

Put context the closest to where it's used

An advice is to put Providers the closest to where it's being used. I mean do not put all your Providers at the top of your app. It will help you to dive in the codebase, with separation of concern and should help React to be slightly faster because would not have to cross all the tree components.

Note: You will need to put some Provider at the top of your application. For example: I18nProvider, SettingsProvider, UserProvider, ...

Doing this, you will may encounter some performance issues when parent re-render if you pass an object as value (most of the time it will be the case).

For example if you have:

const MyContext = React.createContext();

function MyProvider({ children }) {
  const [data, setData] = useState(null);

  const onClick = (e) => {
    // Whatever process
  };

  return (
    <MyContext.Provider value={{ data, onClick }}>
      {children}
    </MyContext.Provider>
  );
}

function ComponentUsingContext() {
  const { onClick } = useContext(MyContext);

  return <button onClick={onClick}>Click me</button>;
}

const MemoizedComponent = React.memo(ComponentUsingContext);

function App() {
  const [counter, setCount] = useState(0);

  return (
    <div>
      <button
        onClick={() => setCounter((prev) => prev + 1)}
      >
        Increment counter: counter
      </button>
      <MyProvider>
        <MemoizedComponent />
      </MyProvider>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this case, when we increment the counter, MemoizedComponent will re-render even it's memoized because the value in the context changes.

In this case the solution is to memoized the value:

const value = useMemo(() => {
  const onClick = (e) => {
    // Whatever process
  };

  return {
    data,
    onClick,
  };
}, [data]);
Enter fullscreen mode Exit fullscreen mode

And tada, MemoizedComponent do not render anymore when incrementing the counter.

Warning: Memoizing the value is only useful if the components using the context are memoized. Otherwise it will just do nothing and continue re-rendering because parent re-renders (in the previous case the parent is App).

Nested providers

It's possible to do nested Provider for same context. It's for example used in the react-router implementation, see my article.

In this case, Consumers will get the value of the closest Provider to them.

const MyContext = React.createContext();

export default function App() {
  return (
    <MyContext.Provider value="parent">
      <ParentSubscriber />
      <MyContext.Provider value="nested">
        <NestedSubscriber />
      </MyContext.Provider>
    </MyContext.Provider>
  );
}

function ParentSubscriber() {
  const value = useContext(MyContext);

  return <p>The value in ParentSubscriber is: {value}</p>;
}

function NestedSubscriber() {
  const value = useContext(MyContext);

  return <p>The value in NestedSubscriber is: {value}</p>;
}
Enter fullscreen mode Exit fullscreen mode

In the previous example, ParentSubscriber will get the value parent and in the other side NestedSubscriber will get nested.


Performance

To talk about performance we are going to do a little music app with few features:

  • be able to see what's our friends are listening
  • show musics
  • show the current music

Important: Do not judge the use of the React context features. I know that for such features we would probably not use React context in real world.

Friends and musics features

Specifications:

  • friends feature consists to fetch every 2sec a fake API that will return an array of object of this type:
type Friend = {
  username: string;
  currentMusic: string;
}
Enter fullscreen mode Exit fullscreen mode
  • musics feature will fetch only once the available music and will return:
type Music = {
  uuid: string; // A unique id
  artist: string;
  songName: string;
  year: number;
}
Enter fullscreen mode Exit fullscreen mode

Okay. Let's implement this.
Innocently, I want to put all this data in a same context and provide it to my application.

Let's implement the Context and Provider:

import React, {
  useContext,
  useEffect,
  useState,
} from "react";

const AppContext = React.createContext();

// Simulate a call to a musics API with 300ms "lag"
function fetchMusics() {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve([
          {
            uuid: "13dbdc18-1599-4a4d-b802-5128460a4aab",
            artist: "Justin Timberlake",
            songName: "Cry me a river",
            year: 2002,
          },
        ]),
      300
    )
  );
}

// Simulate a call to a friends API with 300ms "lag"
function fetchFriends() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve([
        {
          username: "Rainbow",
          currentMusic:
            "Justin Timberlake - Cry me a river",
        },
      ]);
    }, 300)
  );
}

export const useAppContext = () => useContext(AppContext);

export default function AppProvider({ children }) {
  const [friends, setFriends] = useState([]);
  const [musics, setMusics] = useState([]);

  useEffect(() => {
    fetchMusics().then(setMusics);
  }, []);

  useEffect(() => {
    // Let's poll friends every 2sec
    const intervalId = setInterval(
      () => fetchFriends().then(setFriends),
      2000
    );

    return () => clearInterval(intervalId);
  }, []);

  return (
    <AppContext.Provider value={{ friends, musics }}>
      {children}
    </AppContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now let's see the implementations of the Friends and Musics component. Nothing complicated:

function Friends() {
  const { friends } = useAppContext();

  console.log("Render Friends");

  return (
    <div>
      <h1>Friends</h1>
      <ul>
        {friends.map(({ username, currentMusic }) => (
          <li key={username}>
            {username} listening {currentMusic}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And:

function Musics() {
  const { musics } = useAppContext();

  console.log("Render Musics");

  return (
    <div>
      <h1>My musics</h1>
      <ul>
        {musics.map(({ uuid, artist, songName, year }) => (
          <li key={uuid}>
            {artist} - {songName} ({year})
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, I will ask you a question. Do you know what will be rendered / print in the console?

Yep, both Friends and Musics will rendered every around 2sec. Why?
Do you remember that I told you each consumers will be trigger if the value provided changes, even if they use a part of this value that does not change.
It's the case of Musics that only pulls musics, that does not change, from the context.

You can see it in the following codesandbox:

It's why I advise to separate data by business domain in different contexts.

In our example I will make two separate contexts FriendsContext and MusicContext.

You can see the implementation here:


Current listening music

Now we would like to be able to select a music from the list, and listen it.

I'm going to do a new context to store the currentMusic:

import React, { useContext, useState } from "react";

const CurrentMusicContext = React.createContext();

export const useCurrentMusicContext = () =>
  useContext(CurrentMusicContext);

export default function CurrentMusicProvider({ children }) {
  const [currentMusic, setCurrentMusic] =
    useState(undefined);

  return (
    <CurrentMusicContext.Provider
      value={{ currentMusic, setCurrentMusic }}
    >
      {children}
    </CurrentMusicContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

I add a button in the Musics component to listen the associated music:

function MyMusics() {
  const musics = useMusicContext();
  const { setCurrentMusic } = useCurrentMusicContext();

  console.log("Render MyMusics");

  return (
    <div>
      <h1>My musics</h1>
      <ul>
        {musics.map((music) => (
          <li key={music.uuid}>
            {getFormattedSong(music)}{" "}
            <button onClick={() => setCurrentMusic(music)}>
              Listen
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the CurrentMusic component is simply:

function CurrentMusic() {
  const { currentMusic } = useMusicContext();

  console.log("Render CurrentMusic");

  return (
    <div>
      <h1>Currently listening</h1>
      {currentMusic ? (
        <strong>{getFormattedSong(currentMusic)}</strong>
      ) : (
        "You're not listening a music"
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ok, now what is happening when you chose to listen a new music?

Currently, both MyMusics and CurrentMusic will render. Because when the currentMusic changes a new object is passed to the provider.

Separate dynamic and static data

One strategy is to separate dynamic and static data in two different contexts CurrentMusicDynamicContext and CurrentMusicStaticContext:

import React, { useContext, useState } from "react";

const CurrentMusicStaticContext = React.createContext();
const CurrentMusicDynamicContext = React.createContext();

export const useCurrentMusicStaticContext = () =>
  useContext(CurrentMusicStaticContext);
export const useCurrentMusicDynamicContext = () =>
  useContext(CurrentMusicDynamicContext);

export default function CurrentMusicProvider({ children }) {
  const [currentMusic, setCurrentMusic] =
    useState(undefined);

  return (
    <CurrentMusicDynamicContext.Provider
      value={currentMusic}
    >
      <CurrentMusicStaticContext.Provider
        value={setCurrentMusic}
      >
        {children}
      </CurrentMusicStaticContext.Provider>
    </CurrentMusicDynamicContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

And here we go. Just to use the right hook to get value from the context.

Note: As you can see it really complicates the code, that's why do not try to fix performance issue that does not exist.


use-context-selector

The second solution is to use the library made by dai-shi named use-context-selector. I made an article about its implementation.
It will wrap the native context API of React, to give you access to multiple hooks that will re-render your component only if the selected value from the store changed.

The principle is simple, you create your context thanks to the createContext function given by the lib.
Then you select data from it with the useContextSelector. The API is:

useContextSelector(CreatedContext, valueSelectorFunction)
Enter fullscreen mode Exit fullscreen mode

For example if I want to get the currentMusic:

const currentMusic = useContextSelector(
  CurrentMusicContext,
  (v) => v.currentMusic
);
Enter fullscreen mode Exit fullscreen mode

Not to expose the context I made a hook:

export const useCurrentMusicContext = (selector) =>
  useContextSelector(CurrentMusicContext, selector);
Enter fullscreen mode Exit fullscreen mode

And that's all. You can find the code bellow:


Conclusion

We have seen how to use React context, and performance issues that you can encounter.
But like always, do not do premature optimization. Just try to worry about it when there are real problems.
As you have seen, optimization can make your code less readable and more verbose.
Just try to separate different business logics in different context and to put your provider as close as possible to where it's needed, to make things clearer. Do not put everything at the top of your app.
If you have real performance problems because of contexts, you can:

  • separate dynamic and static data in different contexts
  • useMemo the value if it's changing because of parent re-rendering. But you will have to put some memo on components that uses the context (or parent) otherwise it will do nothing.
  • use the use-context-selector lib to solve context's shortcomings. Maybe one day natively in react as you can see in this opened PR.
  • one other strategy that we don't talk about it in this article, is not to use React context but atom state management library like: jotai, recoil, ...

Do not hesitate to comment and if you want to see more, you can follow me on Twitter or go to my Website.

💖 💪 🙅 🚩
romaintrotard
Romain Trotard

Posted on November 10, 2021

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

Sign up to receive the latest update from our blog.

Related