Julian​Garamendy​.dev

Info  |  Work  |  Blog

Why I never use React.useContext

August 13, 2020

Instead of using React.createContext directly, we can use a utility function to ensure the consuming component is rendered within the correct Context Provider.

// JavaScript:
const [BananaProvider, useBanana] = createStrictContext()

// TypeScript:
const [BananaProvider, useBanana] = createStrictContext<Banana>()

Scroll down for the code, or find it in this gist.

The Problem

We would normally create a React Context like this:

const BananaContext = React.createContext()

// ... later ...

const banana = React.useContext(BananaContext) // banana may be undefined

Our banana will be undefined if our component doesn’t have a BananaContext.Provider up in the tree.

This has some drawbacks:

  • Our component needs to check for undefined, or risk a run-time error at some point.
  • If banana is some data we need to render, we now need to render something else when it’s undefined.
  • Basically, we cannot consider our banana an invariant within our component.

Adding a custom hook

I learned this from a blog post by Kent C. Dodds.

We can create a custom useBanana hook that asserts that the context is not undefined:

export function useBanana() {
  const context = React.useContext(BananaContext)
  if(context === undefined) {
    throw new Error('The useBanana hook must be used within a BananaContext.Provider')
  return context
}

If we use this, and never directly consume the BananaContext with useContext(BananaContext), we can ensure banana isn’t undefined, because if it was, we would throw with the error message above.

We can make this even “safer” by never exporting the BananaContext. Exporting only its provider, like this:

export const BananaProvider = BananaContext.Provider

A generic solution

I used the previous approach for several months; writing a custom hook for each context in my app.

Until one day, I was looking through the source code of Chakra UI, and they have a utility function that is much better.

This is my version of it:

import React from 'react'

export function createStrictContext(options = {}) {
  const Context = React.createContext(undefined)
  Context.displayName = options.name // for DevTools

  function useContext() {
    const context = React.useContext(Context)
    if (context === undefined) {
      throw new Error(
        options.errorMessage || `${name || ''} Context Provider is missing`
      )
    }
    return context
  }

  return [Context.Provider, useContext]
}

This function returns a tuple with a provider and a custom hook. It’s impossible to leak the Context, and therefore impossible to consume it directly, skipping the assertion.

We use it like this:

const [BananaProvider, useBanana] = createStrictContext()

Here’s the TypeScript version:

import React from 'react'

export function createStrictContext<T>(
  options: {
    errorMessage?: string
    name?: string
  } = {}
) {
  const Context = React.createContext<T | undefined>(undefined)
  Context.displayName = options.name // for DevTools

  function useContext() {
    const context = React.useContext(Context)
    if (context === undefined) {
      throw new Error(
        options.errorMessage || `${name || ''} Context Provider is missing`
      )
    }
    return context
  }

  return [Context.Provider, useContext] as [React.Provider<T>, () => T]
}

We use it like this:

const [BananaProvider, useBanana] = createStrictContext<Banana>()

Conclusion

We can make errors appear earlier (unfortunately still at runtime) when we render a component outside the required Context Provider by using a custom hook that throws when the context is undefined.

Instead of using React.createContext directly, we use a utility function to create providers and hooks automatically for all the contexts in our app.

Comments?

  • Do you use a similar “pattern”? No? Why not?
  • In which cases would you NOT use something like this?

References:


Julian Garamendy

Written by Julian Garamendy Julian Garamendy's DEV Profile