Using the Context API efficiently

May 26, 2021 — 5 min read

Using the Context API efficiently

What is Context API?

The Context API is a way to share data between components at different levels of an element tree, often used to avoid prop drilling and even as a state manager.

The last case I mentioned about using Context as a state manager is controversial because for a long time Redux, MobX, and others dominated this area, and we used these state managers even when we didn’t need them. However, one thing we should be careful about when using the Context API as a state manager is that any changes to any data will trigger a new render, even if the changed data is not specifically used.

More about Context API

Firstly, depending on when you are reading this post, I recommend checking out this Pull Request in the React repository, where a way to consume only one data from the context is being implemented. I also recommend these articles by master Kent C. Dodds on how to organize a Context file and how to optimize it.

How to use it efficiently

My definition of efficient is with a good API to use and has good performance regardless of the size of the application.

import { createContext, ReactNode, useCallback, useState } from 'react'

interface CounterContextType {
  counter: number
  increment: () => void
  decrement: () => void
}

const counterContext = createContext<CounterContextType>(
  {} as CounterContextType
)

const CounterProvider = ({ children }: { children: ReactNode }) => {
  const [counter, setCounter] = useState(0)

  const increment = useCallback(() => {
    setCounter((c) => c + 1)
  }, [])

  const decrement = useCallback(() => {
    setCounter((c) => c - 1)
  }, [])

  return (
    <counterContext.Provider
      value={{
        counter,
        increment,
        decrement,
      }}
    >
      {children}
    </counterContext.Provider>
  )
}

export { CounterProvider, counterContext }

Normally we define a context like this, I’m going to use a very simple counter example, but let’s imagine something on a larger scale.

To track the renders, I’m going to create a hook and use it in the components.

import { useRef } from 'react'

export const useRenderCounter = (componentName: string) => {
  const renderCount = useRef(0)
  renderCount.current = renderCount.current + 1

  return (
    <>
      <h3>{`${componentName} have: ${renderCount.current} renders`}</h3>
      <hr />
    </>
  )
}

To see the new renders happening, I’m going to separate it into three components that use the above context.

import { useContext } from 'react'

import { useRenderCounter } from '../hooks/useRenderCounter'
import { counterContext } from './ContextDefault'

export const ShowCounter = () => {
  const renderCount = useRenderCounter('ShowCounter')

  const { counter } = useContext(counterContext)

  return (
    <>
      <h2>Counter: {counter}</h2>
      {renderCount}
    </>
  )
}
// ...same imports

export const IncrementCounter = () => {
  const renderCount = useRenderCounter('IncrementCounter')

  const { increment } = useContext(counterContext)

  return (
    <>
      <button type="button" onClick={increment}>
        Increment
      </button>
      {renderCount}
    </>
  )
}
// ...same imports

export const DecrementCounter = () => {
  const renderCount = useRenderCounter('DecrementCounter')

  const { decrement } = useContext(counterContext)

  return (
    <>
      <button type="button" onClick={decrement}>
        Decrement
      </button>
      {renderCount}
    </>
  )
}

With the components created, we can centralize them and use the Provider created in the context file.

import { CounterProvider } from './ContextDefault'

import { ShowCounter } from './ShowCounter'
import { IncrementCounter } from './IncrementCounter'
import { DecrementCounter } from './DecrementCounter'

export const MainDefaultContext = () => {
  return (
    <CounterProvider>
      <h1>Default Context</h1>
      <ShowCounter />
      <IncrementCounter />
      <DecrementCounter />
    </CounterProvider>
  )
}

Testing:

You may have noticed that all components are re-rendered when the counter is updated, even though only the ShowCounter component uses the value.

In this case, we can use the optimization recommended by Kent or the library I want to introduce in this article, use-context-selector. While separating the value from the handler that updates it into different contexts works in many cases, this library is fantastic for using the Context API more comprehensively with a clean API.

Using use-context-selector

The declaration of the context is not much different, it just changes that we use the function provided by the library.

import { ReactNode, useCallback, useState } from 'react'
import { createContext } from 'use-context-selector'

interface CounterContextType {
  counter: number
  increment: () => void
  decrement: () => void
}

const counterContext = createContext<CounterContextType>(
  {} as CounterContextType
)

const CounterProvider = ({ children }: { children: ReactNode }) => {
  const [counter, setCounter] = useState(0)

  const increment = useCallback(() => {
    setCounter((c) => c + 1)
  }, [])

  const decrement = useCallback(() => {
    setCounter((c) => c - 1)
  }, [])

  return (
    <counterContext.Provider
      value={{
        counter,
        increment,
        decrement,
      }}
    >
      {children}
    </counterContext.Provider>
  )
}

export { CounterProvider, counterContext }

Now the biggest difference comes when we use the created Context. We can still consume the context with the useContext hook provided by the library, but the reason we are using it is the useContextSelector hook.

import { useContextSelector } from 'use-context-selector'

import { counterContext } from './ContextSelector'
import { useRenderCounter } from '../hooks/useRenderCounter'

export const ShowCounter = () => {
  const renderCount = useRenderCounter('ShowCounter')

  const counter = useContextSelector(counterContext, ({ counter }) => counter)

  return (
    <>
      <h2>Counter: {counter}</h2>
      {renderCount}
    </>
  )
}

First, we pass the context that will be used and as a second parameter of the hook a handler, in this handler the parameter we receive is the value of the context itself, so I can destructure, do any processing and then return the value that I want for the component based on the values of the context, in this case I took the counter and just returned the value.

Example of changes that can be made considering there is a shopping cart in the context:

const cartInfos = useContextSelector(myContext, ({ cart }) => {
  return {
    cart,
    cartAmount: cart.length,
    cartIsEmpty: !cart.length,
    isHavePotatoOnCart: cart.some((product) => product.name === 'Potato'),
  }
})

Because of this hook and the resources provided, I find it very interesting to use this library when we think about scaling an application using the Context API.

Now going back to the example, the other components to increment and decrement the counter will be the same as the previous ones, changing only the way we consume the value from the context.

// ...same imports

export const IncrementCounter = () => {
  const renderCount = useRenderCounter('IncrementCounter')

  const increment = useContextSelector(
    counterContext,
    ({ increment }) => increment
  )

  return (
    <>
      <button type="button" onClick={increment}>
        Increment
      </button>
      {renderCount}
    </>
  )
}
// ...same imports

export const DecrementCounter = () => {
  const renderCount = useRenderCounter('DecrementCounter')

  const decrement = useContextSelector(
    counterContext,
    ({ decrement }) => decrement
  )

  return (
    <>
      <button type="button" onClick={decrement}>
        Decrement
      </button>
      {renderCount}
    </>
  )
}

And now it’s time to test it, does this hook provide only a more detailed way to consume context values or does it actually impact performance?

Only the components that use the updated values are re-rendered!

Final version with both examples:

Conclusion

Initially, the Context API was not recommended for managing the state of large applications, but looking at the future of that Pull Request, the optimizations mentioned by Kent and the library I showed, it is indeed possible to organize contexts and scale an application using only the Context API to manage these states.

And to conclude, I am not against any other library for managing states, lately I have been looking a lot at Recoil and jotai, both solve this issue of managing states in a clean way, with a neat API and easy to use. If you use GraphQL and Apollo client, I also recommend looking at Reactive Variables, combined with the cache it is simply powerful.


André Zagatti

Frontend software engineer who likes to share knowledge.

A. Zagatti Logo

© 2023, André Zagatti