ReactT Dark_870x220

Context API is a great feature offered by React, but it can be tricky to get it right. Learn how to efficiently create and consume Context API with the use of React Hooks without performance issues. Starting with a naive implementation, we will iterate over what can be improved and how to avoid unnecessary component re-renders.

Since version 16.3, React has had a stable version of Context API that can be used to easily share data between many components. It can be passed down directly to components that need it while avoiding prop drilling. In this article you will learn how to use Context efficiently without introducing performance bottlenecks.

Imagine you have an application that has a global spinner which shows an overlay that covers the whole page while an app is communicating with a server. A function to show and hide a spinner should be accessible from any component in the application.

Let’s start with a simple implementation and then we will iterate through how it can be improved. First, create a new project with create-react-app. If you don’t know, it is a CLI tool for scaffolding React projects. Make sure you have Node.js installed on your machine. If you have any issues with creating a project then check the official site - https://create-react-app.dev/.

npx create-react-app context-app

When the project is ready, we have to create a few files.

src/context/GlobalSpinnerContext.js
src/components/GlobalSpinner/GlobalSpinner.js
src/components/GlobalSpinner/globalSpinner.css
src/components/RandomComments.js

Naive Implementation

In the GlobalSpinnerContext.js file we will create our Context logic and GlobalSpinnerContext provider, while the GlobalSpinner folder will have the Spinner component and styles. RandomComments.js file will fetch comments from an API and will trigger GlobalSpinner when needed.

src/components/RandomComments.js

The RandomComments component will render a list of comments. When it is mounted, it will make an API call to get comments and then use setComments to update the state and display them.

import React, {useState, useEffect} from 'react'

const RandomComments = props => {
  const [comments, setComments] = useState([])
  useEffect(() => {
    (async () => {
      const result = await fetch('https://jsonplaceholder.typicode.com/comments')
      const data = await result.json()
      setComments(data)
    })()
  }, [])

  return (
    <div>
      {comments.map(comment => {
        const {name, body, id} = comment
        return (
          <div key={id}>
            <p style={{fontWeight: 'bold'}}>{name}</p>
            <p> {body}</p>
          </div>
        )
      })}
    </div>
  )
}

export default RandomComments

src/components/GlobalSpinner/GlobalSpinner.js

Simple component which has an overlay and Loading text. You can be fancier if you like.

import React from 'react'
import './globalSpinner.css'

const GlobalSpinner = props => {
  return (
    <div className="global-spinner-overlay">
      <p>Loading...</p>
    </div>
  )
}

export default GlobalSpinner

src/components/GlobalSpinner/globalSpinner.css

Styling for the overlay and loading text.

.global-spinner-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.3);
  font-size: 30px;
  color: white;
  font-weight: bold;
  display: flex;
  justify-content: center;
  align-items: center;
}

src/App.js

Imports and renders GlobalSpinner and RandomComments.

import React from 'react';
import './App.css';
import GlobalSpinner from './components/GlobalSpinner/GlobalSpinner'
import RandomComments from './components/RandomComments'
function App() {
  return (
      <div className="App">
        <GlobalSpinner />
        <RandomComments />
      </div>
  );
}

export default App;

If you run your project with the npm run start command, you should see a gray background with Loading text in the middle. We will not be getting fancy with beautiful-looking spinners, as what we currently have should be enough to go through the Context implementation.

After creating necessary files and updating App.js file, head to the GlobalSpinnerContext.js file.

import React, {createContext} from ‘react’

const GlobalSpinnerContext = createContext()

export default GlobalSpinnerContext

This is the simplest implementation where we create a context and then export it. This context could be imported and used in App.js as shown in the picture below:

App.js

import React from 'react';
import './App.css';
import GlobalSpinner from './components/GlobalSpinner/GlobalSpinner'
import GlobalSpinnerContext from './context/GlobalSpinnerContext';
import RandomComments from './components/RandomComments'

function App() {
  return (
    <GlobalSpinnerContext.Provider>
      <div className="App">
        <GlobalSpinner />
        <RandomComments />
      </div>
    </GlobalSpinnerContext.Provider>
  );
}

export default App;

However, we would have to write stateful logic for the spinner in App.js as well. Instead, let’s create a ContextProvider component which will encapsulate this logic and keep the App.js file clean.

In GlobalSpinnerContext.js we are going to create a GlobalSpinnerContextProvider component. Note that the GlobalSpinnerContext constant is not a default export anymore. The ContextProvider will use useState hook to store and update visibility state for the spinner. The first attempt for a working solution could look like this:

import React, { useState, createContext } from 'react'

export const GlobalSpinnerContext = createContext()

const GlobalSpinnerContextProvider = (props) => {
  const [isGlobalSpinnerOn, setGlobalSpinner] = useState(false)

  return (
    <GlobalSpinnerContext.Provider value={{isGlobalSpinnerOn, setGlobalSpinner}}>
        {props.children}
    </GlobalSpinnerContext.Provider>
  )
}

export default GlobalSpinnerContextProvider

Don’t forget to update App.js file as we use Context.Provider inside of the GlobalSpinnerContext.js file.

App.js

import React from 'react';
import './App.css';
import GlobalSpinner from './components/GlobalSpinner/GlobalSpinner'
import GlobalSpinnerContextProvider from './context/GlobalSpinnerContext';
import RandomComments from './components/RandomComments'
function App() {
  return (
    <GlobalSpinnerContextProvider>
      <div className="App">
        <GlobalSpinner />
        <RandomComments />
      </div>
    </GlobalSpinnerContextProvider>
  );
}

export default App;

Then in the GlobalSpinner component we can import the GlobalSpinnerContext and use it with useContext hook.

GlobalSpinner.js

import React, {useContext} from 'react'
import './globalSpinner.css'
import {GlobalSpinnerContext} from '../../context/GlobalSpinnerContext'

const GlobalSpinner = props => {
  const {isGlobalSpinnerOn} = useContext(GlobalSpinnerContext)
  return isGlobalSpinnerOn ? (
    <div className="global-spinner-overlay">
      <p>Loading...</p>
    </div>
  ) : null
}

export default GlobalSpinner

If you check the website, you will see that the overlay with the spinner has disappeared. This is because we set the spinner value to be false by default. In the same manner, we can import and use the GlobalSpinnerContext in the RandomComments component. However, this time we don’t need the isGlobalSpinnerOn value, but instead we need access to the setGlobalSpinner function.

RandomComments.js

import React, {useState, useEffect, useContext} from 'react'
import {GlobalSpinnerContext} from '../context/GlobalSpinnerContext'

const RandomComments = props => {
  const [comments, setComments] = useState([])
  const {setGlobalSpinner} = useContext(GlobalSpinnerContext)
  useEffect(() => {
    (async () => {
      setGlobalSpinner(true)
      const result = await fetch('https://jsonplaceholder.typicode.com/comments')
      const data = await result.json()
      setComments(data)
      setGlobalSpinner(false)
    })()
  }, [setGlobalSpinner])

  return (
    <div>
      {comments.map(comment => {
        const {name, body, id} = comment
        return (
          <div key={id}>
            <p style={{fontWeight: 'bold'}}>{name}</p>
            <p> {body}</p>
          </div>
        )
      })}
    </div>
  )
}

export default RandomComments

This is a very simple implementation which works for this scenario, but there are problems with it.

GlobalSpinnerContext Improvements

First issue is about how we are passing isGlobalSpinnerOn and setGlobalSpinner to the Provider.

<GlobalSpinnerContext.Provider value={{isGlobalSpinnerOn, setGlobalSpinner}}>
    {props.children}
</GlobalSpinnerContext.Provider>

All context consumers are re-rendered whenever a value passed to the Provider changes. This means that if we change visibility of the spinner or a parent component re-renders, both GlobalSpinner and RandomComments components will re-render. This is because we are creating a new inline object for the Provider value. One way to fix this is to use useMemo hook which would memoize the value object. It would only be re-created when isGlobalSpinnerOn value changes.

import React, { useState, createContext, useMemo } from 'react'

export const GlobalSpinnerContext = createContext()

const GlobalSpinnerContextProvider = (props) => {
  const [isGlobalSpinnerOn, setGlobalSpinner] = useState(false)

  const value = useMemo(() => ({
    isGlobalSpinnerOn,
    setGlobalSpinner
  }), [isGlobalSpinnerOn])

  return (
    <GlobalSpinnerContext.Provider value={value}>
        {props.children}
    </GlobalSpinnerContext.Provider>
  )
}

export default GlobalSpinnerContextProvider

This fixes the issue of re-creating a new object on every render and thus re-rendering all consumers. Unfortunately, we still have a problem.

Avoiding Re-rendering of All Context Consumers

As we have it now, a new value object will be created whenever spinner visibility changes. However, while the GlobalSpinner component relies on the isGlobalSpinnerOn, it does not rely on the setGlobalSpinner function. Likewise, RandomComments requires access to the setGlobalSpinner function only. Therefore, it does not make sense to have RandomComments re-render every time the spinner visibility changes, as the component does not directly depend on it. Therefore, to avoid this issue we can create another context to separate isGlobalSpinnerOn and setGlobalSpinner.

import React, { useState, createContext } from 'react'

export const GlobalSpinnerContext = createContext()
export const GlobalSpinnerActionsContext = createContext()

const GlobalSpinnerContextProvider = (props) => {
  const [isGlobalSpinnerOn, setGlobalSpinner] = useState(false)

  return (
    <GlobalSpinnerContext.Provider value={isGlobalSpinnerOn}>
      <GlobalSpinnerActionsContext.Provider value={setGlobalSpinner}>
        {props.children}
      </GlobalSpinnerActionsContext.Provider>
    </GlobalSpinnerContext.Provider>
  )
}

export default GlobalSpinnerContextProvider

Thanks to having two context providers components can consume exactly what they need. Now, we need to update GlobalSpinner and RandomComments components to consume correct values.

GlobalSpinner.js

The only change is that we don’t destructure isGlobalSpinnerOn anymore.

import React, {useContext} from 'react'
import './globalSpinner.css'
import {GlobalSpinnerContext} from '../../context/GlobalSpinnerContext'

const GlobalSpinner = props => {
  const isGlobalSpinnerOn = useContext(GlobalSpinnerContext)
  return isGlobalSpinnerOn ? (
    <div className="global-spinner-overlay">
      <p>Loading...</p>
    </div>
  ) : null
}

export default GlobalSpinner

RandomComments.js

We import ‘GlobalSpinnerActionsContext’ instead of ‘GlobalSpinnerContext’. Also, we don’t destructure ‘setGlobalSpinner’ function anymore.

import React, {useState, useEffect, useContext} from 'react'
import {GlobalSpinnerActionsContext} from '../context/GlobalSpinnerContext'

const RandomComments = props => {
  const [comments, setComments] = useState([])
  const setGlobalSpinner = useContext(GlobalSpinnerActionsContext)
  useEffect(() => {
    (async () => {
      setGlobalSpinner(true)
      const result = await fetch('https://jsonplaceholder.typicode.com/comments')
      const data = await result.json()
      setComments(data)
      setGlobalSpinner(false)
    })()
  }, [setGlobalSpinner])

We have successfully fixed our performance issue. However, there are still improvements that can be made. However, these are not about the performance, but the way we consume Context values.

Consuming Context in a Nice Way

To consume spinner context values in any component, we have to import the context directly as well as the useContext hook. We can make it a bit less tedious by using a wrapper for the useContext hook call. Head to the GlobalSpinnerContext.js file. We will not be exporting Context values directly anymore, but instead custom functions to consume contexts.

GlobalSpinnerContext.js

import React, { useState, createContext, useContext } from 'react'

const GlobalSpinnerContext = createContext()
const GlobalSpinnerActionsContext = createContext()

export const useGlobalSpinnerContext = () => useContext(GlobalSpinnerContext)
export const useGlobalSpinnerActionsContext = () => useContext(GlobalSpinnerActionsContext)

const GlobalSpinnerContextProvider = (props) => {
  const [isGlobalSpinnerOn, setGlobalSpinner] = useState(false)

  return (
    <GlobalSpinnerContext.Provider value={isGlobalSpinnerOn}>
      <GlobalSpinnerActionsContext.Provider value={setGlobalSpinner}>
        {props.children}
      </GlobalSpinnerActionsContext.Provider>
    </GlobalSpinnerContext.Provider>
  )
}

export default GlobalSpinnerContextProvider

Next, we have to update GlobalSpinner and RandomComments and replace direct use of useContext hook in favor of wrapper functions.

GlobalSpinner.js

import React from 'react'
import './globalSpinner.css'
import {useGlobalSpinnerContext} from '../../context/GlobalSpinnerContext'

const GlobalSpinner = props => {
  const isGlobalSpinnerOn = useGlobalSpinnerContext()
  return isGlobalSpinnerOn ? (
    <div className="global-spinner-overlay">
      <p>Loading...</p>
    </div>
  ) : null
}

export default GlobalSpinner

RandomComments.js

import React, {useState, useEffect} from 'react'
import {useGlobalSpinnerActionsContext} from '../context/GlobalSpinnerContext'

const RandomComments = props => {
  const [comments, setComments] = useState([])
  const setGlobalSpinner = useGlobalSpinnerActionsContext()
  useEffect(() => {
    (async () => {
      setGlobalSpinner(true)
      const result = await fetch('https://jsonplaceholder.typicode.com/comments')
      const data = await result.json()
      setComments(data)
      setGlobalSpinner(false)
    })()
  }, [setGlobalSpinner])

We don’t have to import useContext and spinner Contexts directly anymore. Instead, we have an interface to consume these values. There is another useful improvement we can make. useContext should only be called inside a Context.Provider. To ensure we don’t make the mistake of using a context outside of a Provider, we can check if there is any context value.

import React, { useState, createContext, useContext } from 'react'

const GlobalSpinnerContext = createContext()
const GlobalSpinnerActionsContext = createContext()

export const useGlobalSpinnerContext = () => {
  const context = useContext(GlobalSpinnerContext)
  if (context === undefined) {
    throw new Error(`useGlobalSpinnerContext must be called within GlobalSpinnerContextProvider`)
  }
  return context
}

export const useGlobalSpinnerActionsContext = () => {
  const context = useContext(GlobalSpinnerActionsContext)
  if (context === undefined) {
    throw new Error(`useGlobalSpinnerActionsContext must be called within GlobalSpinnerContextProvider`)
  }
  return context
}

As you can see on the picture above, instead of returning a result of useContext immediately, we first check the context value. If it is undefined, an error is thrown. Nevertheless, it would be a bit repetitive to do it for every useContext consumer function, so let’s abstract it into reusable factory function.

import React, {useState, createContext, useContext} from 'react'

const GlobalSpinnerContext = createContext()
const GlobalSpinnerActionsContext = createContext()

/* eslint-disable */
const useContextFactory = (name, context) => {
  return () => {
  const ctx = useContext(context)
    if (ctx === undefined) {
      throw new Error(`use${name}Context must be used withing a ${name}ContextProvider.`)
    }
    return ctx
  }
}
/* eslint-enable */

export const useGlobalSpinnerContext = useContextFactory('GlobalSpinnerContext', GlobalSpinnerContext)
export const useGlobalSpinnerActionsContext = useContextFactory('GlobalSpinnerActionsContext', GlobalSpinnerActionsContext)

The useContextFactory function accepts name parameter which will be used in an error message and context parameter that will be consumed. You might need to disable eslint for the useContextFactory as it might throw an error that useContext cannot be called inside a callback. This eslint error is thrown because the function useContextFactory starts with the word use, which is reserved for hooks. You can rename the function to something else like factoryUseContext.

In this article we covered how to use and consume Context the right way while avoiding performance bottlenecks. You can find a GitHub repo for this project at https://github.com/ThomasFindlay/react-using-context-api-right-way.


Thomas Findlay
About the Author

Thomas Findlay

Thomas Findlay is a web and mobile developer, mentor, technical writer and consultant with almost six years of experience. He specializes in frontend web technologies including Vue.js, React.js, React Native for mobile applications, along with backend: PHP, Laravel, Python, Flask, Node.js and Express.js. Thomas has designed and developed websites and mobile applications for individuals and small and large businesses. Learn more about Thomas at https://www.codementor.io/thomas478

Related Posts

Comments

Comments are disabled in preview mode.