useContextSelector: Speeding Up React Apps With Large Context

Guillaume PiersonFrançois Zaninotto
#js#performance#react#popular

Have you ever had a context that was so big it negatively impacted your React app's performance? This happens because a single change in the context value rerenders all the components that depend on that context.

Large Contexts Cause Re-Renders

The classic example is a theme context, storing all the colors, fonts, and other styles of the UI.

import { createContext, useState } from 'react';

const defaultTheme = { color: '#aabbcc', fontFamily: 'Arial', fontSize: 16 };

export const ThemeContext = createContext(defaultTheme);

export const ThemeContextProvider = ({ value = defaultTheme, children }) => {
    const [theme, setTheme] = useState(value);
    return (
        <ThemeContext.Provider value={{ theme, setTheme }}>
            {children}
        </ThemeContext.Provider>
    );
};

export const useTheme = () => {
    const { theme } = useContext(ThemeContext);
    return theme;
}

Given the entire app is wrapped inside a <ThemeContextProvider>, you can use this context to read the theme in your components:

import { useTheme } from './ThemeContext';

const Welcome = () => {
    const { color } = useTheme();
    return <div style={{ color }}>Hello!</div>;
};

But if a component changes a single setting in the context, like the font size for instance, all the components that depend on the theme context will rerender.

const ChangeFontSizeButtons = () => {
    const { setTheme } = useContext(ThemeContext);
    const handleIncreaseFontSize = () => {
        setTheme((theme) => ({ ...theme, fontSize: theme.fontSize + 2 }));
    };
    const handleDecreaseFontSize = () => {
        setTheme((theme) => ({ ...theme, fontSize: theme.fontSize - 2 }));
    };
    return (
        <span>
        <button onClick={handleIncreaseFontSize}>+</button>
        <button onClick={handleDecreaseFontSize}>-</button>
        </span>
    );
};

Whenever a user presses any of the buttons above, the <Welcome> component will rerender, even though it doesn't depend on the font size.

How can you avoid this issue?

Splitting Components With Memo

One technique consists of splitting components that depend on the context into one part that depends on the entire context, and another that depends on only a pat of the context, and to memoize that second part.

import { memo } from 'react';

const Welcome = () => {
    const { color } = useTheme();
    return <WelcomeBody color={color} />
}

const WelcomeBody = memo(({ color }) => {
    return <div style={{ color }}>Hello!</div>;
})

This way, only the main component (<Welcome>) will rerender when an unrelated part of the context changes. The inner component (<WelcomeBody>) will only rerender if the color has changed. This will reduce the time spent in rerendering.

This technique works but it requires to split every component that depends on a large context. It's long, error-prone, and sometimes counterproductive (memo adds to the initial render time).

The Split Context Alternative

An alternative solution for not rerendering components that depend on a large context is to split the context into smaller one.

In the case of a theme context, you could split it into a ColorContext and a FontContext:

import { createContext, useState } from 'react';

const defaultTheme = { color: '#aabbcc', fontFamily: 'Arial', fontSize: 16 };

export const ThemeContext = createContext(defaultTheme);
export const ColorContext = createContext(defaultTheme.color);
export const FontFamilyContext = createContext(defaultTheme.fontFamily);
export const FontSizeContext = createContext(defaultTheme.fontSize);

export const ThemeContextProvider = ({ value = defaultTheme, children }) => {
    const [theme, setTheme] = useState(value);

    return (
        <ThemeContext.Provider value={{ theme, setTheme }}>
            <ColorContext.Provider value={theme.color}>
                <FontFamilyContext.Provider value={theme.fontFamily}>
                    <FontSizeContext.Provider value={theme.fontSize}>
                        {children}
                    </FontSizeContext.Provider>
                </FontFamilyContext.Provider>
            </ColorContext.Provider>
        </ThemeContext.Provider>
    );
};

Components that need to access the theme can now use specialized contexts:

import { useContext } from 'react';
import { ColorContext } from './ThemeContext';

const Welcome = () => {
    const color = useContext(ColorContext);
    return <div style={{ color }}>Hello!</div>;
};

This technique is used e.g. by the react-admin library to avoid rerendering the entire list page when the pagination state changes.

export const ListContextProvider = ({ value, children }) => (
    <ListContext.Provider value={value}>
        <ListFilterContext.Provider value={usePickFilterContext(value)}>
            <ListSortContext.Provider value={usePickSortContext(value)}>
                <ListPaginationContext.Provider value={usePickPaginationContext(value)}>
                    {children}
                </ListPaginationContext.Provider>
            </ListSortContext.Provider>
        </ListFilterContext.Provider>
    </ListContext.Provider>
);

This solution is easier than splitting components, but it requires that each component subscribes to the right context. It can be cumbersome if you have a lot of contexts.

State Management Libraries

Another alternative is, of course, Redux. It was created before React's Context and hooks, but it's still relevant today. Redux allows you to split a state into multiple reducers, and to subscribe to only the part of the state you need.

import { useSelector } from 'react-redux';

const Welcome = () => {
    const color = useSelector(state => state.color);
    return <div style={{ color }}>Hello!</div>;
};

Redux has been criticized for being too verbose and complex for simple use cases. This has been partially addressed with the introduction of the Redux Toolkit, which simplifies the setup and usage of Redux. But the learning curve is still steeper than using React's Context. Redux is a good solution for large apps with complex state management, but it might be overkill for smaller apps.

Recoil and Zustand are lightweight alternatives to Redux, that allow you to split the state into multiple atoms or stores. They are easier to use than Redux, but they are less popular and have less community support.

import { useRecoilValue } from 'recoil';

const Welcome = () => {
    const color = useRecoilValue(colorState);
    return <div style={{ color }}>Hello!</div>;
};

All these state management libraries propose higher-level abstractions (like atoms and stores) to avoid rerendering the entire app when a part of the state changes. But do we really need those abstractions?

The use-context-selector Library

But what if I told you there is a better way to optimize performance for large contexts? Enter use-context-selector!

use-context-selector is an open-source package that reads a part of a context, and only re-renders when that part changes. The idea is to avoid re-rendering the component when the other part of the context value changes.

The syntax builds up on the useContext hook, but with an additional argument: a selector function. This function is called with the context value as an argument and should return the selected part of the context value.

const value = useContextSelector(MyContext, value => value.myValue);

If you've ever used Redux and useSelector, this hook is similar.

Using useContextSelector

First, you need to install the package:

yarn add use-context-selector scheduler

Next, create a context with createContext from use-context-selector:

import { createContext } from 'use-context-selector';

const defaultTheme = { color: '#aabbcc', fontFamily: 'Arial', fontSize: 16 };

export const ThemeContext = createContext(defaultTheme);

export const ThemeProvider = ({ value, children }) => {
    const [theme, setTheme] = useState(value);
    return (
        <ThemeContext.Provider value={{ theme, setTheme }}>
            {children}
        </ThemeContext.Provider>
    );
};

Use ThemeProvider to wrap your component:

<ThemeProvider value={{ color: "blue" }}>
    <MyComponent />
</ThemeProvider>

That's it! You can now use useContextSelector to select a part of the context value and avoid re-rendering your component when the other part of the context changes.

import { useContextSelector } from 'use-context-selector';
import { ThemeContext } from './ThemeContext';

const MyComponent = () => {
    const color = useContextSelector(ThemeContext, value => value.color);
    return <div style={{ color }}>hello</div>;
};

This code will only re-render the component when color changes in the context.

A good practice is to create a custom hook to avoid repeating the same code:

export const useColor = () => useContextSelector(ThemeContext, value => value.color);

There are more hooks available in the library, like useContext(). This hook works like the original useContext() and can be used when you need to access the whole context value.

There is also useContextUpdate(fn) that allows you to update the context value with concurrent rendering in React 18. There are others that I haven't tested, but you can find them in the documentation.

One major drawback of this library is that it requires the Context to be created using the library. That means that you can't use the useContextSelector() hook with a context created using React's createContext() function. For example, if you use a UI library that provides a theme context, you can't use useContextSelector() with it.

Performance Comparison

I created a small React website to test the performance of useContextSelector compared to the naive useContext. You can find the code on GitHub: marmelab/use-context-selector-demo

Here is a quick comparison of the performance of the two hooks using the demo and 10,000 blocks utilizing the context value, each block using a subpart of the context value:

By using 10,000 blocks with the native useContext: Rerender with native react

By using 10,000 blocks with useContextSelector: Rerender with library step 1 Rerender with library step 2

In my code, using the library requires two steps. The library updates its internal state, and then the library updates the hooks for each block that needs to be re-rendered.

In the pure context version, react re-renders all the blocks when the context value changes, but with useContextSelector, only the blocks that need to be re-rendered are updated. With pure contexts, it takes 370ms to re-render all the blocks, and with the library, it takes 3ms to re-render only the blocks that need to be re-rendered. It's a 99% performance improvement!

However, we also need to see if it has a negative impact on cold start when the app is loaded for the first time.

By using 10,000 blocks with the native useContext:

Cold start with native react part 1 Cold start with native react part 2

By using 10,000 blocks with useContextSelector:

Cold start with library part 1 Cold start with library part 2 Cold start with library part 3

Again, the library required an additional step. By adding the execution times of the steps, it takes 1156ms to load the app with native React, and 996ms with the library. The difference is not significant and can change depending on the context value size. However, the library does not have a negative impact on cold start.

The library seems to be quicker than using native React context in a microbenchmark. In a more complex apps, the gains aren't as spectacular, but useContextSelectorstill provides a performance boost.

Will This Be Necessary In React 19?

The React core team has announced React Compiler, a.k.a. React Forget. It's an optional compiler for React that will be available in React 19. It is already available for test as a third-party package for React 18.

In theory, React Compiler will remove the need for manual useMemo. Will it solve the issue of re-rendering components that depend on a large context? Probably not, as the "Splitting Components With Memo" technique requires both memoization and splitting components.

The author of use-context-selector created an RFC in React to add this feature to the React core. It was 5 years ago, and the proposal hasn't been accepted or rejected yet. It's still open, but it's not clear if it will be implemented in the future.

Conclusion

If you have a large context that negatively impacts your React app's performance, there is no silver bullet.

If you control the context, you can use useContextSelector to speed up rendering. It's fast and it uses a familiar API. If not, use any of the classic techniques to split components or contexts, or a state management library.

As a side node, you should always test the performance before and after using such optimizations, as their effect may be counter intuitive in some cases.

Did you like this article? Share it!