Careful with Context Composition
Recently, I helped a small team fix a performance issue involving React Context. It was a fairly simple fix that I want to share with you. I’m going to set up the problem, show you the small change we needed to make, and share a small library I made to help you out in the future.
The Problem - Poor composition
To understand this post, we need to establish a shared context (pun intended). We need to understand that whenever state changes in a React app, the component in which the change occurs will be rerendered. That means rerendering all the components that are called in that component’s function body. Let’s make this clear with some examples. I’m going to create a Box
. The background color for Box
will change every time it renders. We’ll force it to rerender with a forceUpdate
function:
import { Button } from './Button'
import Flex from './Flex'
// A spacing helper function from my shevyjs library
// It's an alias of the `baseSpacing` method
// Spacer uses this under the hood
import { bs } from './shevy'
// These can be found on my snippets page
import randomRGB from './snippets/randomRGB'
import useForceUpdate from './snippets/useForceUpdate'
function Box() {
return (
<div
style={{
backgroundColor: randomRGB(),
height: bs(4),
}}
/>
)
}
function App() {
const forceUpdate = useForceUpdate()
return (
<Flex direction="column" gap={1}>
<Button onClick={forceUpdate}>Force Update</Button>
<Box />
</Flex>
)
}
And we can see it here:
Perfect. The background color of Box
changes every time it’s rendered and it rerenders due to a state change. Now, let’s add React Context to the mix.
Let’s create a Context
, Provider
, and a custom hook for consuming that context. We’re going to use forceUpdate
as the only value
passed to the Provider
.
import React from 'react'
const MyContext = React.createContext()
function MyProvider({ children }) {
const forceUpdate = useForceUpdate()
return <MyContext.Provider value={forceUpdate}>{children}</MyContext.Provider>
}
const useMyContext = () => React.useContext(MyContext)
Now, I’m going to purposely make a bad choice, and add a Box
component to MyProvider
. Notice that Box
will now be in the same scope that the state change associated with forceUpdate
occurs (because forceUpdate
creates a state change inside the useForceUpdate
hook).
function MyProvider({ children }) {
const forceUpdate = useForceUpdate()
return (
<MyContext.Provider value={forceUpdate}>
{children}
<Box />
</MyContext.Provider>
)
}
Now, let’s change the App
a bit, using our new Context
and custom hook.
function Trigger() {
// Remember that the only value of our context is `forceUpdate`
// but we purposely want to consume our context at the moment
const forceUpdate = useMyContext()
return <Button onClick={forceUpdate}>Force Update</Button>
}
function App() {
return (
<MyProvider>
<Trigger />
</MyProvider>
)
}
Now, you will notice that the resulting UI is exactly the same. We have a button on top of a box. Here’s the thing to pay attention to: Typically, only consumers of a context will be forced to rerender when the context value
updates, but because we made a bad decision and put our Box
in the same function that has a state change, it’s going to rerender, too. Look at it:
If you’re not paying attention, you may be confused why Box
is rerendering when it doesn’t consume your context, but it all has to do with how we chose to make our composition. Let’s fix it now.
The Solution - Only expose children
in a Provider
The problem is we’re rendering a component in the same scope as the state change of our context. If we move Box
from MyProvider
and into our App
instead, we’ll have the same UI, but Box
won’t rerender when we click the Trigger
.
// No more `Box` in this component
function MyProvider({ children }) {
const forceUpdate = useForceUpdate()
return <MyContext.Provider value={forceUpdate}>{children}</MyContext.Provider>
}
function App() {
return (
<MyProvider>
<Flex direction="column" gap={1}>
<Trigger />
<Box />
</Flex>
</MyProvider>
)
}
And let’s see it in action. Notice, Box
will not rerender. Click the Trigger
all you want, it will not change.
I assure you, a state change is still occurring, but because Box
is not rendered in the scope of the state change, but rather as one of the children
of MyProvider
, it won’t rerender. Our UI is exactly the same in all scenarios so far, but we can see how using the correct composition improves how our app functions.
A More Robust Solution: react-generate-context
I’m a believer that if you can put guide rails in place to keep people on a good path, you should use them. When I saw the mistake the team was making, I realized I could build a small package that puts these guide rails in place. Say hello to react-generate-context
.
This library is a single function, generateContext
, that receives a custom hook to manage the value
for your Context
and returns to you the Provider
and custom hook for that context.
The main feature? Because you don’t have direct access to the Provider
, you can’t put any other components inside of it.
import generateContext from 'react-generate-context'
const [MyProvider, useMyContext] = generateContext(() => {
const forceUpdate = useForceUpdate()
return forceUpdate
})
Notice that the function we pass to generateContext
is itself a custom hook. We use it to establish the very same value we did before. We can consume useMyContext
in Trigger
just like before, too.
To make a slightly better example, and the one you’ll find if you visit the Github page for it, let’s make a Counter
:
const useCounterValue = ({ startingCount = 0 }) => {
const [state, setState] = React.useState(startingCount)
const handlers = React.useMemo(
() => ({
inc: () => {
setState(s => s + 1)
},
dec: () => {
setState(s => s - 1)
},
}),
[],
)
return [state, handlers]
}
const [CounterProvider, useCounter] = generateContext(useCounterValue)
function Counter() {
const [count, { inc, dec }] = useCounter()
return (
<div>
<div>{count}</div>
<div>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
</div>
</div>
)
}
function App() {
return (
<CounterProvider startingCount={100}>
<Counter />
</CounterProvider>
)
}
And here it is in action:
Using this package, it’s impossible to screw up your Provider
in a way that creates unnecessary rerenders for components. That’s a win in my book.
Summary
Resist any urge to put other components in the scope of your custom Provider
for a React Context. Doing so means rerendering those components whenever the state of the context changes. Instead, only expose children
from your custom Provider
and compose components under the Provider
. Using react-generate-context
removes some of this boilerplate for you.