Kyle Shevlin

Software Engineer
October 18, 2021
0 strokes bestowed

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 Spacer from './Button'
// 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 (
    <div>
      <Spacer bottom={1}>
        <Button onClick={forceUpdate}>Force Update</Button>
      </Spacer>
      <Box />
    </div>
  )
}

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>
      <Spacer bottom={1}>
        <Trigger />
      </Spacer>
    </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>
      <Spacer bottom={1}>
        <Trigger />
      </Spacer>
      <Box />
    </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:

100

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.


Finished reading?

Here are a few options for what to do next.

Like
Liked the post? Click the beard up to 50 times to show it
Share
Sharing this post on Twitter & elsewhere is a great way to help me out
Support
Was this post valuable to you? Make a donation to show it
Make a Donation
Kofi logo

Related Post:
How to Use React Context Effectively
Tags
React

Kyle Shevlin's face, which is mostly a beard with eyes
Kyle Shevlin is a software engineer who specializes in JavaScript, React and front end web development.

Let's talk some more about JavaScript, React, and software engineering.

I write a newsletter to share my thoughts and the projects I'm working on. I would love for you to join the conversation. You can unsubscribe at any time.

Data Structures and Algorithms Logo
Data Structures and Algorithms

Check out my courses!

Liked the post? You might like my courses, too. Click the button to view this course or go to Courses for more information.
I would like give thanks to those who have contributed fixes and updates to this blog. If you see something that needs some love, you can join them. This blog is open sourced at https://github.com/kyleshevlin/blog
alexloudenjacobwsmithbryndymentJacobMGEvanseclectic-codingjhsukgcreativeerikvorhesHaroenvmarktnoonandependabotmarcuslyonsbrentmclarkfederico-fiorinimedayzDoNormal
©2021 Kyle Shevlin. All Rights Reserved.