The issue with this combination is how React updates that components that are subscribed to a Context.
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>
);
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>
);
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.
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 👇.