Data Sharing in React Applications (PART 2): Context API.

Data Sharing in React Applications (PART 2): Context API.

ยท

7 min read

In the previous article, I covered two approaches to sharing data across our application; by making redundant network requests and prop drilling. We also saw the limitations of both these approaches.

Today we shall explore React Context as an improvement on the two approaches.

React's Context API is an inbuilt feature that allows data to be passed through the component tree without the need to pass props manually at every level. It's a way to share values like themes, user details and other global data between components without explicitly passing them through each component manually.

  1. Create a Context

The first thing we do is create a context object. We do this in a contexts folder inside the src folder.

A context object is a container for sharing data across the component tree without having to pass props down manually at every level. It is where the values to be shared between components will be held.

Context is created using the createContext() function from React, whose only argument is the default value.

import {createContext} from "react";

const SuperheroesContext = createContext(null);

export default SuperheroesContext;

The code above sets up the structure for using context in a React application. It doesn't yet define how the context will be used or what data it will hold, but it establishes the framework for sharing data globally within the application's component hierarchy.

The SuperheroesContext variable holds this created context and will be used to provide and consume data within the component tree.

REMEMBER: Always name your contexts (both the files and variables holding the contexts) descriptively to convey the intent/purpose of each context. SuperheroesContext indicates that the context here relates to superheroes, making it clear what kind of data might be expected to be stored or shared within this context.

  1. Create a Context Provider.

Next, we create a Context Provider. Context Provider is a React function component responsible for making the context (created in the previous section) and its associated data available to descendant components in the component tree.

It does so by wrapping the portion of the tree where the context should be accessible with the Provider component. This enables components wrapped within the provider to access and consume the context values via the useContext hook (which we shall see later).

The Context Provider takes a single children prop which represents the child components that it will wrap.

import SuperheroesContext from './SuperheroesContext';

const SuperheroesContextProvider = ({ children }) => {
    return (
        <SuperheroesContext.Provider>
            {children}
        </SuperheroesContext.Provider>
    );
};

export default SuperheroesContextProvider;

Some developers prefer to create a context and its provider in a single file but my preference is to have them separated to reduce the amount of boilerplate code within a single file. It also makes each file more readable.

It's quite common to encounter provider implementations like the one shown below, which combines the creation of context and its provider into a single step:

import {createContext} from "react";

const SuperheroesContext = createContext(null);

const SuperheroesContextProvider = ({ children }) => {
    return (
        <SuperheroesContext.Provider>
            {children}
        </SuperheroesContext.Provider>
    );
};

export default SuperheroesContextProvider;

Within this file, we define the states we wish to share among components along with any necessary methods for those state's manipulation. We do this before the return statement, as typically done in any React component.

In our scenario, the states related to superheroes are already defined within the useFetch hook from the previous article. Therefore, there's no need to redefine them here; we simply import the hook into our file.

import SuperheroesContext from './SuperheroesContext';
import { useFetch } from '../hooks/useFetch';

const SuperheroesContextProvider = ({ children }) => {
    const { data: superheroes } = useFetch('superheroes');

    return (
        <SuperheroesContext.Provider value={{ superheroes }}>
            {children}
        </SuperheroesContext.Provider>
    );
};

export default SuperheroesContextProvider;

The JSX returned in this component is a Provider component provided by React. This is what enables the context values to be accessed by descendant components.

The value passed into Provider component must be in the form of an object. Where many states are defined, it's common to encapsulate these values within an object, which we then pass into the value attribute of the provider component.

This helps maintain organization and clarity, especially when managing multiple state values within a single context.

import SuperheroesContext from './SuperheroesContext';
import { useFetch } from '../hooks/useFetch';

const SuperheroesContextProvider = ({ children }) => {
    const { data: superheroes } = useFetch('superheroes');

    const contextValue = { superheroes }; // add other state and methods you want to share between components in this object and pass it to the context provider as below

    return (
        <SuperheroesContext.Provider value={contextValue}>
            {children}
        </SuperheroesContext.Provider>
    );
};

export default SuperheroesContextProvider;
  1. Handle errors with a custom hook

At this stage, our Context Provider implementation is nearly complete. However, without centralized error handling, each component consuming the context would be responsible for its own error management, particularly if attempting to access the context in a component not wrapped by it.

Imagine a scenario where 10 components consume the context; this would mean duplicating boilerplate error handling code 10 times. Not only is that too repetitive, but where different engineers are developing the UI components consuming the contexts, your application risks having error handling that is not standard. One may prefer rendering the errors in a heading, another to throw a console error etc.

To centralize error handling, we finalize our Provider implementation by defining a custom hook below the export default statement.


export const useSuperheroesContext = () => {
    const context = useContext(SuperheroesContext);

    if (!context) {
        throw new Error(
            'useSuperheroesContext must be used within a SuperheroesContextProvider'
        );
    }

    return context;
};

The useContext hook allows child components to consume the context values provided by our provider.

REMEMBER: The Provider component makes the context values available to the components while the useContext hook is what allows the components to use those values.

Our provider file now looks like this:

import { useContext } from 'react';
import SuperheroesContext from './SuperheroesContext';
import { useFetch } from '../hooks/useFetch';

const SuperheroesContextProvider = ({ children }) => {
    const { data: superheroes } = useFetch('superheroes');

    return (
        <SuperheroesContext.Provider value={{ superheroes }}>
            {children}
        </SuperheroesContext.Provider>
    );
};

export default SuperheroesContextProvider;

export const useSuperheroesContext = () => {
    const context = useContext(SuperheroesContext);

    if (!context) {
        throw new Error(
            'useSuperheroesContext must be used within a SuperheroesContextProvider'
        );
    }

    return context;
};
  1. Wrap the components where Context values are needed.

We are ready to consume the Superheroes context.

As mentioned earlier, to make the context available to components, we wrap those components reliant on those states within the context provider.

In App.jsx, we refactor our previous code from this:

import SuperheroList from './components/SuperheroList';
import AddSuperhero from './components/AddSuperhero';
import SuperheroCount from './components/SuperheroCount';
import { useFetch } from './hooks/useFetch';


const App = () => {
    const { data: superheroes } = useFetch('superheroes');
    return (
        <div className="app">
            <SuperheroCount superheroes={superheroes}/>
            <div>
                <SuperheroList superheroes={superheroes} />
                <AddSuperhero />
            </div>
        </div>
    );
};

export default App;

To this:

import SuperheroList from './components/SuperheroList';
import AddSuperhero from './components/AddSuperhero';
import SuperheroCount from './components/SuperheroCount';
import SuperheroesContextProvider from './contexts/SuperheroesContextProvider';

const App = () => {
    return (
        <div className="app">
            <SuperheroesContextProvider>
                <SuperheroCount />
                <div>
                    <SuperheroList />
                    <AddSuperhero />
                </div>
            </SuperheroesContextProvider>
        </div>
    );
};

export default App;

Now that we're using useFetch within the context, we no longer need to have it directly in App.jsx. Instead, we wrap our components with SuperheroesContextProvider, which instantly grants access to the superheroes data to them..

  1. Consuming the context values within child components.

Also worth noting in the above code snippet is that we eliminated prop drilling. With that, we should omit the props passed in both the SuperheroCount and SuperheroList components from the previous code.

In the SuperheroesList component, we now have the following simplified code:

const SuperheroList = () => {
    const { superheroes } = useSuperheroesContext();

    return (
        <table>
            {/*same as before*/}
        </table>
    );
};

export default SuperheroList;

Instead of a superheroes prop, the useSuperheroesContext hook is called. The hook returns the context value (remember the value we passed into the Provider component?). We destructure the superheroes property from the context value and use it in place of the previous prop.

We do the same in the SuperheroCount component and that's all!

Let us go back to the browser and try adding a new superhero to our table:

Everything works just as we expect the app to behave. A new superhero is added to the table and the count is instantly updated.

Conclusion

We have successfully refactored our code from making redundant network requests, to using props to now using the Context API.

With Context, we can create a single source of truth for data that needs to be accessed by multiple components throughout the application. This not only improves our developer experience but also enhances our application's performance by reducing unnecessary data fetching and prop drilling.

Looking ahead, in our next article, we will delve into React Query, another essential tool in the React ecosystem. React Query offers an even richer solution for managing server state, caching, and fetching data from APIs.

Happy Coding! ๐Ÿ˜Š

ย