Leveraging React Context and Custom Hooks for Efficient State Sharing and Data Loading

by Tifani Dermendzhieva

Introduction

In modern web development, building complex applications requires efficient and seamless communication between components. One of the challenges developers often face is managing and sharing state across multiple components without introducing unnecessary complexity or tight coupling. This is where React Context comes to the rescue. It is one of React's core concepts, which allows data to be passed down the component tree without explicitly passing props through each intermediate component.

While React's built-in context management offers a way to share data globally within the application, combining it with custom hooks provides a more elegant and efficient solution for loading and managing context data, enabling developers to build more easily maintainable and scalable applications.

Understanding React Context

Before delving into custom hooks, let's recap what context is in React. React context is a tool for sharing data, such as theme preferences, user authentication data, or any kind of state, across an entire component tree without having to explicitly pass those values through each component's props. This is particularly useful for scenarios where multiple components at different levels in the component tree need access to the same data.

Creating a Context

To start using React context, you need to create a context using the React.createContext(). This function returns an object containing two components: Provider and Consumer. For example, let's create a context to store the count of fruit available in a smoothie shop:

// Creating a context
export const FruitContext = React.createContext();

We can use the Provider component to distribute the data from the context to the components, responsible for presenting the data (aka view components).

Since we want to be able to work with the data in the context thorugh the app, we can set the data as state inside the Provider and define some callback functions, which will be used to update the state.

For example, consider we have two types of fruit - bananas and apples. The quantity of each fruit will be loaded from the backend with an API call - getBananasCount(), getApplesCount().

// Creating a context provider component
export function FruitContextProvider({ children }) {
  const [bananasCount, setBananasCount] = React.useState(0);
  const [applesCount, setApplesCount] = React.useState(0);

  const [isLoading, setIsLoading] = React.useState(true);

  const getBananasCount = useCallback(() => {
    setIsLoading(true);
    axios
      .get("http://localhost:3000/bananas")
      .then((res) => {
        const countAsNum = Number(res.data.count);
        setBananasCount(countAsNum);
      })
      .catch((e) => console.log("Error fetching bananas.", e))
      .finally(() => setIsLoading(false));
  }, [setBananasCount, setIsLoading]);

  const getApplesCount = useCallback(() => {
    setIsLoading(true);
    axios
      .get("http://localhost:3000/apples")
      .then((res) => {
        const countAsNum = Number(res.data.count);
        setApplesCount(countAsNum);
      })
      .catch((e) => console.log("Error fetching apples.", e))
      .finally(() => setIsLoading(false));
  }, [setApplesCount, setIsLoading]);

  // Load the supplies from server
  useEffect(() => {
    console.log("loading supplies from server ...");
    getApplesCount();
    getBananasCount();
  }, [getApplesCount, getBananasCount]);

  // all data and methods you want to use outside of the context
  const exposedData = {
    isLoading,
    bananasCount,
    applesCount,
  };

  return (
    <FruitContext.Provider value={exposedData}>
      {children}
    </FruitContext.Provider>
  );
}

To consume the context in a component, ensure it's wrapped with the relevant context provider.

In our example, we would like to access the context inside the <SupplyDisplay/> and <SmoothieMaker/> components, so all that matters to us is that these are wrapped by the FruitContextProvider in App.js.

In larger applications, however, you should be mindful where you place the Provider in order to avoid wrapping components which do not use the context in question. In such cases, make sure you place the provider closer to the components, where the context will be consumed.

// App.js

import React from "react";
import { FruitContextProvider } from "./context/FruitContext";
import SupplyDisplay from "./components/SupplyDisplay";
import SmoothieMaker from "./components/SmoothieMaker";

function App() {
  return (
    <div
      className="App"
      style={{
        width: "60%",
        margin: "auto",
        marginTop: "3%",
        padding: "3%",
        boxShadow: "rgba(0, 0, 0, 0.16) 0px 1px 4px",
      }}
    >
      <header className="smoothie-shop">
        <h1 style={{ fontFamily: "Montserat", color: "green" }}>
          Smoothie Shop
        </h1>
        <FruitContextProvider>
          <SupplyDisplay />
          <SmoothieMaker />
        </FruitContextProvider>
      </header>
    </div>
  );
}

export default App;

Having setup the context provider, let's introduce some more functionality. We'll check if there are enough bananas and apples for a smoothie using the canMakeSmoothie state variable and checkIfCanMakeSmoothie method.

Assume that we need 3 bananas and 2 apples for a smoothie. The updated fruit context provider will look like this:

// Creating a context provider component
export function FruitContextProvider({ children }) {
  const [bananasCount, setBananasCount] = React.useState(0);
  const [applesCount, setApplesCount] = React.useState(0);
  const [canMakeSmoothie, setCanMakeSmoothie] = React.useState(false);

  const [isLoading, setIsLoading] = React.useState(true);

  const getBananasCount = useCallback(() => {
    setIsLoading(true);
    axios
      .get("http://localhost:3000/bananas")
      .then((res) => {
        const countAsNum = Number(res.data.count);
        setBananasCount(countAsNum);
      })
      .catch((e) => console.log("Error fetching bananas.", e))
      .finally(() => setIsLoading(false));
  }, [setBananasCount, setIsLoading]);

  const getApplesCount = useCallback(() => {
    setIsLoading(true);
    axios
      .get("http://localhost:3000/apples")
      .then((res) => {
        const countAsNum = Number(res.data.count);
        setApplesCount(countAsNum);
      })
      .catch((e) => console.log("Error fetching apples.", e))
      .finally(() => setIsLoading(false));
  }, [setApplesCount, setIsLoading]);

  const checkIfCanMakeSmoothie = useCallback(() => {
    // refresh supplies count
    getBananasCount();
    getApplesCount();

    // assume that for a smoothie we need 3 bananas and 2 apples
    if (bananasCount >= 3 && applesCount >= 2) {
      setCanMakeSmoothie(true);
    } else {
      setCanMakeSmoothie(false);
    }
  }, [
    bananasCount,
    applesCount,
    setCanMakeSmoothie,
    getApplesCount,
    getBananasCount,
  ]);

  // Load the supplies from server & check if a smoothie can be made
  useEffect(() => {
    console.log("loading supplies from server ...");
    checkIfCanMakeSmoothie();
  }, [checkIfCanMakeSmoothie]);

  // all data and methods you want to use outside of the context
  const exposedData = {
    isLoading,
    errorMsg,
    bananasCount,
    applesCount,
    canMakeSmoothie,
  };

  return (
    <FruitContext.Provider value={exposedData}>
      {children}
    </FruitContext.Provider>
  );
}

Furthermore, we would like to add a button "Make smoothie", which will update the quantity of each fruit accordingly. The button will only be visible if there is enough fruit for a smoothie (i.e. at least 3 bananas and 2 apples). Alternatively, the user will see a message that there aren't enough supplies.

The function which will be executed onClick of the "Make smoothie" button will also be defined in the context provider. With this, the final version of our /src/context/FruitContext.js is:

import React, { useCallback, useEffect } from "react";
import axios from "axios";

// Creating a context
export const FruitContext = React.createContext();

// Creating a context provider component
export function FruitContextProvider({ children }) {
  const [bananasCount, setBananasCount] = React.useState(0);
  const [applesCount, setApplesCount] = React.useState(0);
  const [canMakeSmoothie, setCanMakeSmoothie] = React.useState(false);

  const [isLoading, setIsLoading] = React.useState(true);
  const [errorMsg, setErrorMsg] = React.useState("");

  const getBananasCount = useCallback(() => {
    setIsLoading(true);
    axios
      .get("http://localhost:3000/bananas")
      .then((res) => {
        const countAsNum = Number(res.data.count);
        setBananasCount(countAsNum);
      })
      .catch((e) => console.log("Error fetching bananas.", e))
      .finally(() => setIsLoading(false));
  }, [setBananasCount, setIsLoading]);

  const getApplesCount = useCallback(() => {
    setIsLoading(true);
    axios
      .get("http://localhost:3000/apples")
      .then((res) => {
        const countAsNum = Number(res.data.count);
        setApplesCount(countAsNum);
      })
      .catch((e) => console.log("Error fetching apples.", e))
      .finally(() => setIsLoading(false));
  }, [setApplesCount, setIsLoading]);

  const checkIfCanMakeSmoothie = useCallback(() => {
    // refresh supplies count
    getBananasCount();
    getApplesCount();

    // assume that for a smoothie we need 3 bananas and 2 apples
    if (bananasCount >= 3 && applesCount >= 2) {
      setCanMakeSmoothie(true);
    } else {
      setCanMakeSmoothie(false);
    }
  }, [
    bananasCount,
    applesCount,
    setCanMakeSmoothie,
    getApplesCount,
    getBananasCount,
  ]);

  const makeSmoothie = useCallback(() => {
    if (!canMakeSmoothie) {
      setErrorMsg("Oh oh! Unsufficient supplies!");
      return;
    }

    // Update bananas count
    const updatedBananasCount = bananasCount - 3;
    axios
      .put("http://localhost:3000/bananas", {
        count: updatedBananasCount,
      })
      .then((res) => {
        if (res.ok) {
          setBananasCount(updatedBananasCount);
        }
      })
      .catch((e) => {
        console.log("Error updating bananas count.", e);
        setErrorMsg("Failed to update apples count.");
      });

    // Update apples count
    const updatedApplesCount = applesCount - 2;
    axios
      .put("http://localhost:3000/apples", {
        count: updatedApplesCount,
      })
      .then((res) => {
        if (res.ok) {
          setApplesCount(updatedApplesCount);
        }
      })
      .catch((e) => {
        console.log("Error updating apples count.", e);
        setErrorMsg("Failed to update apples count.");
      });

    // check if we can continue making smoothies
    checkIfCanMakeSmoothie();
  }, [
    canMakeSmoothie,
    setErrorMsg,
    bananasCount,
    setBananasCount,
    applesCount,
    setApplesCount,
    checkIfCanMakeSmoothie,
  ]);

  // Load the supplies from server & check if a smoothie can be made
  useEffect(() => {
    console.log("loading supplies from server ...");
    checkIfCanMakeSmoothie();
  }, [checkIfCanMakeSmoothie]);

  // all data and methods you want to use outside of the context
  const exposedData = {
    isLoading,
    errorMsg,
    bananasCount,
    applesCount,
    canMakeSmoothie,
    makeSmoothie,
  };

  return (
    <FruitContext.Provider value={exposedData}>
      {children}
    </FruitContext.Provider>
  );
}

Accessing the Context with useContext hook (default way)

Once a context is created, React provides a concise way to consume it using the useContext hook. This hook allows functional components to access the context directly:

import React, { useContext } from "react";
import { FruitContext } from "../context/FruitContext.js";

function SmoothieMaker() {
  const contextData = useContext(FruitContext);

  // Use contextData here
}

Benefits of Using React Context (with default useContext hook)

  1. Simplifies Prop Drilling: React Context eliminates the need to pass props down through multiple levels of components, reducing code complexity and improving code maintainability.

  2. Global State Management: Context provides a global state accessible to any component within its scope. This is particularly useful for managing application-wide data, such as user authentication status or theme preferences.

  3. Code Organization: Context allows you to organize your components more logically by reducing the need for intermediary components just for the sake of passing data.

  4. Component Reusability: Context enables the creation of more reusable components, as they can consume the context without being tightly coupled to their parent components.

Understanding Custom Hooks

While React's built-in context management offers a default hook to share data globally within an application, combining it with custom hooks can provide an even more elegant and efficient solution for loading and managing context data.

Custom hooks are a way to encapsulate reusable logic and state in a composable and organized manner. They allow developers to extract logic from the view components, making them more focused on presentation. By creating custom hooks that handle the context loading and management, we can achieve cleaner and more maintainable code.

Loading Context Data with Custom Hooks

Here's how you can utilise custom hooks to load and manage context data more efficiently in a React application:

Step 1: Create the custom hook

Start by creating a new JS file for your custom hook. Let's call it useFruitContext.js.

import React from "react";
import { FruitContext } from "../context/FruitContext";

// Custom hook to provide the context data
export function useFruitContext() {
  const context = React.useContext(FruitContext);

  if (!context) {
    throw new Error(
      "useFruitContext must be used within the scope of FruitContextProvider"
    );
  }

  return context;
}

Step 2: Implement the custom hook to load the context in a component

Now, let's use the custom hook to load the context data in a component SupplyDisplay, which will display the available quantity of each fruit:

import React from "react";
import { useFruitContext } from "../hooks/useFruitContext";

function SupplyDisplay() {
  const { isLoading, bananasCount, applesCount } = useFruitContext();

  return (
    <>
      {isLoading ? (
        <p> Loading ... </p>
      ) : (
        <>
          <p> At the moment we have the following supplies: </p>
          <ul>
            <li> bananas: {bananasCount} </li>
            <li> apples: {applesCount} </li>
          </ul>
        </>
      )}
    </>
  );
}

export default SupplyDisplay;

Step 2: Use the custom hook to access the context data in another component

Let's introduce one more component - SmoothieMaker, which will check if the current fruit supply is sufficient for a glass of smoothie and, if it is, it will ask the user if they would like one. Alternatively, it will display a message - "Sorry, we cannot make a smoothie at the moment. Please, check again later!"

import React from "react";
import { useFruitContext } from "../hooks/useFruitContext";

function SmoothieMaker() {
  const { isLoading, canMakeSmoothie, makeSmoothie } = useFruitContext();

  if (isLoading) {
    return <p> Loading ... </p>;
  }

  return (
    <>
      {canMakeSmoothie ? (
        <>
          <p style={{ color: "green" }}>Would you like to have a smoothie ?</p>
          <button type="button" onClick={() => makeSmoothie()}>
            Yes, please!
          </button>
        </>
      ) : (
        <p style={{ color: "darkred" }}>
          Sorry, we cannot make a smoothie at the moment. Please, check again
          later!
        </p>
      )}
    </>
  );
}

export default SmoothieMaker;

Voila! Sharing state between components is a breeze with the context and custom hook approach. It keeps your code clean, separates business logic from presentation logic, and facilitates easy refactoring or expansion.

Benefits of Using Custom Hooks for Context Loading

Using custom hooks for loading context data offers several advantages:

  1. Separation of Concerns: Custom hooks allow you to separate the logic of loading and managing context data from the presentation components, resulting in cleaner and more organized code.

  2. Reusability: The custom hook can be easily reused across different parts of your application, reducing duplication of code and ensuring consistent data loading behavior.

  3. Readability: Custom hooks make the code more readable by abstracting away complex logic and providing a clear and descriptive interface for accessing context data.

  4. Testing: Custom hooks can be tested independently, ensuring that the data loading logic is functioning as expected.

Conclusion

React context is a powerful tool that simplifies state management and communication between components in a React application by providing a centralized way to share data without the need for excessive prop drilling. Understanding how to create and use contexts empowers developers to tackle complex state-sharing scenarios with ease, resulting in more modular and efficient applications as well as better development experience.

Furthermore, by encapsulating the context loading logic into a reusable hook, developers can achieve cleaner, easily maintainable, and highly readable code. This approach promotes separation of concerns and facilitates code reuse, making it easier to manage complex state and data loading scenarios in large-scale applications.

How can we help?

We're passionate about solving challenges and turning exciting ideas into reality, together with you. If you have any questions or need assistance with your projects, we're here to help. Don't hesitate to get in touch!

Book a Call
or send a message