If you have been working with React for a while, you might have been tempted to use React context and a state hook (useState or useReducer) to manage the global state of your web app. If you can use something that comes with React for free, why would you resort to using a separate library?

In this post, I will show you the biggest issue with React context that makes it a poor choice for managing global state.

TLDR; If you use React context plus a state hook (useState or useReducer) to manage global state, you will get poor performance by default. You can manually optimize your code to improve performance but that is very tedious (see below). You can avoid this pain by using a state management library. Most state management libraries are optimized out of the box to update components in the most performant way.

Hand drawn illustration that shows a JS object being pushed into a tube labelled context

What is React context?

React Context is a mechanism in React to share values across different components in a component hierarchy. It solves the problem of "prop drilling" where you have to copy a value through several layers of components to provide it to a component deep in the tree. You can avoid this problem with React Context by feeding the value into the "Provider" at the top and then "consuming" it in any nested component.

Hand drawn illustration of prop drilling Hand drawn illustration of React Context

What is global state?

Global state is the data structure that stores the state required by several portions of a web app. It usually consists of several values so often it is an object of objects so that each value can be better organized and easily retrieved.

Why is React Context unsuitable for managing global state?

React Context alone cannot manage global state. When we talk about state management, we usually refer to a mechanism that allows us to set and retrieve values while also being notified when these values change. Context allows us to only send values to all the components that subscribe to it. To use it to manage state, we need to combine it with the useState or useReducer hooks.

The example below contains a very simple implementation of this combination. To simulate the nested components of a real application, this example has multiple Box components placed within each other.

(Note: The code in the code boxes on this page is incomplete. You can view the complete code for each example by running the project and viewing the editor.)

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

const CountContext = createContext(null);

const ProviderBox = ({ children }) => {
const [count, setCount] = useState(0);
return (
<Box>
<CountContext.Provider value={count}>
{children}
<button onClick={() => setCount(count => count + 1)}>
Increment
</button>
</CountContext.Provider>
</Box>
)
}

const ConsumerBox = () => {
const count = useContext(CountContext);
return (
<Box>
<Number value={count} />
</Box>
)
}

const App = () => (
<Box>
<ProviderBox>
<Box>
<Box>
<ConsumerBox />
</Box>
</Box>
</ProviderBox>
</Box>
);

The issue with this combination is how React updates that components that are subscribed to a Context.

React Context's update behavior

React Context is like a wormhole. You feed it a value at the top and it magically appears out of all the components subscribed to it. This makes it unsuitable for global state. When you update the value given to the context provider, all components that are subscribed to that Context will also update. If you have a component that uses only a portion of the global state, that component will still re-render even if the portion of state it uses has not changed.

You can see this behavior for yourself in the example below. In it, the boxes change color every time they are re-rendered. Notice how increasing the left count also causes the count box on the right to re-render even though its value has not changed.

const Box = ({ children }) => {
const backgroundColor = getRandomColor();
return (
<div className="box" style={{ backgroundColor }}>
{children}
</div>
);
};

export const CountContext = createContext(null);

const ProviderBox = ({ children }) => {
const [count, setCount] = useState({ left: 0, right: 0 });
return (
<Box>
<CountContext.Provider value={count}>
{children}
<Row>
<Column>
<Box>
<button onClick={() => setCount(count => ({ ...count, left: count.left + 1 }))}>
Increment left
</button>
</Box>
</Column>
<Column>
<Box>
<button onClick={() => setCount(count => ({ ...count, right: count.right + 1 }))}>
Increment right
</button>
</Box>
</Column>
</Row>
</CountContext.Provider>
</Box>
)
}

const LeftConsumerBox = () => {
const { left } = useContext(CountContext);
return (
<Box>
<Number value={left} />
</Box>
)
}

const RightConsumerBox = () => {
const { right } = useContext(CountContext);
return (
<Box>
<Number value={right} />
</Box>
)
}

const App = () => (
<Box>
<ProviderBox>
<Row>
<Column>
<Box>
<Box>
<LeftConsumerBox />
</Box>
</Box>
</Column>
<Column>
<Box>
<Box>
<RightConsumerBox />
</Box>
</Box>
</Column>
</Row>
</ProviderBox>
</Box>
);

Overcoming this behavior

We can overcome this behavior by splitting the single Context into several Contexts. So we need to create separate Contexts for the left and right counters in the example above as shown below.

const LeftCountContext = createContext(null);
const RightCountContext = createContext(null);

const ProviderBox = ({ children }) => {
const [count, setCount] = useState({ left: 0, right: 0 });
return (
<Box>
<LeftCountContext.Provider value={count.left}>
<RightCountContext.Provider value={count.right}>
{children}
<Row>
<Column>
<Box>
<button onClick={() => setCount(count => ({ ...count, left: count.left + 1 }))}>
Increment left
</button>
</Box>
</Column>
<Column>
<Box>
<button onClick={() => setCount(count => ({ ...count, right: count.right + 1 }))}>
Increment right
</button>
</Box>
</Column>
</Row>
</RightCountContext.Provider>
</LeftCountContext.Provider>
</Box>
)
}

const LeftConsumerBox = () => {
const left = useContext(LeftCountContext);
return (
<Box>
<Number value={left} />
</Box>
)
}

const RightConsumerBox = () => {
const right = useContext(RightCountContext);
return (
<Box>
<Number value={right} />
</Box>
)
}

const App = () => (
<Box>
<ProviderBox>
<Row>
<Column>
<Box>
<Box>
<LeftConsumerBox />
</Box>
</Box>
</Column>
<Column>
<Box>
<Box>
<RightConsumerBox />
</Box>
</Box>
</Column>
</Row>
</ProviderBox>
</Box>
);

Notice how increasing the count on the left doesn't cause the count on the right to re-render. However, the button boxes still re-render. This is because the useState hook lives there and React re-renders the component to which a state hook belongs to when the state is updated. This is inefficient here because the buttons within that box don't change as the count changes.

We can stop this component from re-rendering by creating a separate component for the useState hook to live in. We also need to create another component to provide the state setter function to only the button. Since we have two Contexts, we need two sets of these components.

/* Components for the left counter and button */
const LeftCountContext = createContext(null);

const LeftProvider = ({ children }) => {
const [left, setLeft] = useState(0);
return (
<LeftCountContext.Provider value={{ left, setLeft }}>
{children}
</LeftCountContext.Provider>
)
}

const LeftConsumerBox = () => {
const { left } = useContext(LeftCountContext);
return (
<Box>
<Number value={left} />
</Box>
)
}

const LeftCountButtonBox = () => {
const { setLeft } = useContext(LeftCountContext);
return (
<Box>
<button onClick={() => setLeft(left => left + 1)}>
Increment left
</button>
</Box>
);
};

/* Components for the right counter and button */
const RightCountContext = createContext(null);

const RightProvider = ({ children }) => {
const [right, setRight] = useState(0);
return (
<RightCountContext.Provider value={{ right, setRight }}>
{children}
</RightCountContext.Provider>
)
}

const RightConsumerBox = () => {
const { right } = useContext(RightCountContext);
return (
<Box>
<Number value={right} />
</Box>
)
}

const RightCountButtonBox = () => {
const { setRight } = useContext(RightCountContext);
return (
<Box>
<button onClick={() => setRight(right => right + 1)}>
Increment right
</button>
</Box>
);
};

const ProviderBox = ({ children }) => {
return (
<Box>
<LeftProvider>
<RightProvider>
{children}
<Row>
<Column>
<LeftCountButtonBox />
</Column>
<Column>
<RightCountButtonBox />
</Column>
</Row>
</RightProvider>
</LeftProvider>
</Box>
)
}

const App = () => (
<Box>
<ProviderBox>
<Row>
<Column>
<Box>
<Box>
<LeftConsumerBox />
</Box>
</Box>
</Column>
<Column>
<Box>
<Box>
<RightConsumerBox />
</Box>
</Box>
</Column>
</Row>
</ProviderBox>
</Box>
);

This version is still not perfect since the button component re-renders every time the count increases even though its contents don't change. To prevent this, we need to split the Context yet again to have the state value and state updater functions in separate Contexts.

const LeftCountContext = createContext(null);
const LeftCountUpdaterContext = createContext(null);

const LeftProvider = ({ children }) => {
const [left, setLeft] = useState(0);
return (
<LeftCountContext.Provider value={left}>
<LeftCountUpdaterContext.Provider value={setLeft}>
{children}
</LeftCountUpdaterContext.Provider>
</LeftCountContext.Provider>
)
}

const LeftConsumerBox = () => {
const left = useContext(LeftCountContext);
return (
<Box>
<Number value={left} />
</Box>
)
}

const LeftCountButtonBox = () => {
const setLeft = useContext(LeftCountUpdaterContext);
return (
<Box>
<button onClick={() => setLeft(left => left + 1)}>
Increment left
</button>
</Box>
);
};

/* RightProvider, RightConsumerBox and RightCountButtonBox are same as the Left components above
but with separate contexts and state (i.e. RightCountContext, RightCountUpdaterContext) */

const ProviderBox = ({ children }) => {
return (
<Box>
<LeftProvider>
<RightProvider>
{children}
<Row>
<Column>
<LeftCountButtonBox />
</Column>
<Column>
<RightCountButtonBox />
</Column>
</Row>
</RightProvider>
</LeftProvider>
</Box>
)
}

const App = () => (
<Box>
<ProviderBox>
<Row>
<Column>
<Box>
<Box>
<LeftConsumerBox />
</Box>
</Box>
</Column>
<Column>
<Box>
<Box>
<RightConsumerBox />
</Box>
</Box>
</Column>
</Row>
</ProviderBox>
</Box>
);

Is it worth it?

As you can see from the examples above, getting React context to update components without any unnecessary re-renders is a very tedious process.

On the other hand, here is the same example above implemented with the Zustand state management library instead of React context and useState.

import { create } from 'zustand';

const useCountStore = create(set => ({
left: 0,
right: 0,
incrementLeft: () => set(state => ({ left: state.left + 1 })),
incrementRight: () => set(state => ({ right: state.right + 1 })),
}))

const ProviderBox = ({ children }) => {
const incrementLeft = useCountStore(state => state.incrementLeft);
const incrementRight = useCountStore(state => state.incrementRight);
return (
<Box>
{children}
<Row>
<Column>
<Box>
<button onClick={incrementLeft}>
Increment left
</button>
</Box>
</Column>
<Column>
<Box>
<button onClick={incrementRight}>
Increment right
</button>
</Box>
</Column>
</Row>
</Box>
)
}

const LeftConsumerBox = () => {
const left = useCountStore(state => state.left);
return (
<Box>
<Number value={left} />
</Box>
)
}

const RightConsumerBox = () => {
const right = useCountStore(state => state.right);
return (
<Box>
<Number value={right} />
</Box>
)
}

const App = () => (
<Box>
<ProviderBox>
<Row>
<Column>
<Box>
<Box>
<LeftConsumerBox />
</Box>
</Box>
</Column>
<Column>
<Box>
<Box>
<RightConsumerBox />
</Box>
</Box>
</Column>
</Row>
</ProviderBox>
</Box>
);

Notice how this example behaves like the most optimized example but without the extra code needed to tame React context. This is because Zustand is specifically optimized to update components in a performant manner. Other state management libraries are similarly optimized because they are specifically built to manage state. React Context is not optimized for this because it is only concerned with providing the value to all the subscribers.

Conclusion

While you can use React context and a state hook (useState or useReducer) to manage the global state in your web app, it leads to a situation where you need to be constantly vigilant and write extra code to prevent issues with performance.

I believe it is better to use a state management library like Zustand instead so that many of these concerns are taken care of for you. While you pay the price of having an extra package in your project, the price is worth it for the peace of mind it brings.

Not sure which state management library to use? Over the next few months I will be taking an in-depth look at several state management libraries for React, such as Redux toolkit, Zustand and Recoil, in my monthly posts. You can get these posts directly to your inbox by signing up below 👇.

Prabashwara Seneviratne

Written by

Prabashwara Seneviratne

Author. Lead frontend developer.