October 18, 2021

Careful with Context Composition

edit

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.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Need help with your software problems?

My team and I are ready to help you. Hire Agathist to build your next great project or to improve one of your existing ones.

Get in touch
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Agathist
Good software by good people.
Visit https://agath.ist to learn more
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.
Logo for Data Structures and Algorithms
Data Structures and Algorithms
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.