Managing state with React Context

Managing state with React doesn’t need to be complex, and the built-in Context in React is a great way to do that. The documentation is a bit lacking around some frequent use cases like fetching data from a backend API service efficiently, but it’s possible to go pretty far with just the context.

I’ll take you through the step from an API call in a component to wrapping the state management in a context.

The first concern when managing state here is to avoid calling the API every time the component re-render. The barebone solution is to call the API and save it to a state inside a useEffect with an empty dependency array so it runs only once.

import React, { useState } from "react";

const DataDisplay = () => {
  const [data, setData] = useState("");

  useEffect(() => {
    getDataFromAPI().then((result) => setData(result));
  }, []);

  return <div>{data}</div>;
}

export default DataDisplay;

That works when you have only one call, but once you start to fetch that data in multiple places it’s not enough: you will either need to copy-paste the whole thing, or pass the result down as props to child components. Also, you’ll likely want more code to handle error cases, logging and the like, and you won’t want that cluttering all your components.

Using hooks

The next logical step is then be to extract all that logic into a hook. This can be a perfectly good long-term solution if you only call it only once in each page and just want to avoid the code duplication, but don’t forget that the full code of the hook runs each time you use it in a component.

Here is the previous example turned into a hook. I’m only returning the resulting data, but you could also return extra info like a loading state.

import React, { useState } from "react";

const useData = () => {
  const [data, setData] = useState("");

  useEffect(() => {
    getDataFromAPI().then((result) => setData(result));
  }, []);

  return data;
}

export default useData;

If you’re willing to take a dependency, you could also add a caching library such as TanStack Query on top of the API call so it won’t be called more than once, even if the hook runs multiple time.

Now that we have a hook, the component that displays the data need to use that new hook to access the data, and the useEffect will run in the same way it previously did :

import React from "react";
import useData from "./useData";

const DataDisplay = () => {
  const data = useData();

  return <div>{data}</div>;
}

export default DataDisplay;

But what if you have multiple components in the page that needs that same data, or you have some dependency between your hooks? For a complex page, it may not be as clean to pull in a bunch of hooks.

Using a context

That’s where we’re leveraging the React Context: we’ll wrap all the components that needs the data in a context provider, which handles all the fetching and makes sure it runs only once.

Since a context provider is also a component, you can pass any prop you wish to it. You can also access any useful hooks such as the ones we defined previously. It’s not required, but I find it useful to keep each API call in a separate hook so they can be reused to build another provider, or used straight into another page that don’t need that much complexity.

There is a bit more boilerplate for a context than a hook since you’ll need to define some default values for the context, initialize it and export everything.

Here is the data fetching in a provider, with an additional API being fetched so you can see what querying multiple APIs looks like:

import React, { createContext, useContext } from "react";
import useData from "./useData";
import useOtherData from "./useOtherData";

export const DataProvider = ({children}) => {
  const { data } = useData();
  const { otherData } = useOtherData();

  return (
    <DataContext.Provider value={{ data, otherData }}>
      {children}
    </DataContext.Provider>
  );
};

const dataDefault = {
  data: "",
  otherData: "",
};

const DataContext = createContext(dataDefault);

export const useDataContext = () => useContext(DataContext);

Once your provider is ready, you’ll then wrap your top component in the provider: the context will be accessible in each child component under the provider. I’m using one object as the return value, so I can destructure only the parts I need for that component.

Here are the final parent and child component now using the context we just defined:

import React from "react";
import DataProvider from "./DataProvider";
import DataChild from "./DataChild";

const DataDisplay = () => {
  return <DataProvider><DataChild/></DataProvider>;
}

export default DataDisplay;
import { useDataContext } from "./DataProvider";

const DataChild = () => {
  const { otherData } = useDataContext();

  return <div>{otherData}</div>;
}

export default DataChild;