Context and Reducer: The Power Duo for State Management in React

Context and Reducer: The Power Duo for State Management in React

Understand Context and Reducer in React, why they are needed and how to use them.

Introduction:

React is a popular JavaScript library for building user interfaces. It allows developers to create reusable components that can be used across different parts of an application.

One of the core concepts of React is managing state.

State in React refers to the data that is used to render components.

This data can come from various sources such as user input, server responses, or internal application events.

State management is crucial in building complex React applications where multiple components need to share and update data. By properly managing state, developers can build more organized, efficient, and scalable applications.

React provides two important tools for managing state: context and reducer. In this blog, we will discuss what they are, why they are needed, and how can you use them in your React applications.

Context:

Context is a way to share data between components in a React application, without having to pass data down through every level of the component tree. It provides a way to make certain data available to all components in the tree, regardless of how deeply nested they are. It is especially useful for data that is needed by many components, such as the current user’s authentication status or the theme of the application.

Context is created using the createContext() function. This function returns an object that contains two properties: Provider and Consumer. The Provider component is used to wrap the parts of the component tree that need access to the context data, while the Consumer component is used to access the data from within those components.

Reducer:

A reducer is a function that takes in two arguments: the current state and an action, and returns a new state. The purpose of a reducer is to manage state predictably. Reducers are commonly used in React applications, particularly when working with context.

Reducers are used to update the state in response to an action. Actions are plain JavaScript objects that describe what happened in the application. Actions typically have a type property, which describes the type of action that occurred, and a payload property, which contains any data associated with the action.

Understanding some basic vocab:

As we are dealing with a ton load of different things, short definitions of those, or a black-box overview before we dive deep into the mini-tutorial can be helpful in understanding them fully.

  • context⁣ – an API given to us by React, allowing for the passing of information to child components without the use of props.

  • reducer⁣ – a pure function, accepting a state & action, and returning a new state.

  • action⁣ – an object literal, which describes a change to the state.

  • useContext⁣ – a React hook, that allows functional components to take advantage of the context API.

  • useReducer⁣ – a React hook, used in place of useState, generally for more complex state.

  • dispatch⁣ – a function returned to us by useReducer, which sends action objects to the reducer function.

A Mini Tutorial:

Let’s say we are building a simple e-commerce application that displays a list of products. We want to be able to add or remove items to a shopping cart and update the total price of the cart.

We can use context and reducer to manage the state of this application.

  • Create a React Project:

To get started, create a new React project by running the following command in the terminal of your choice:

npx create-react-app my-app

We are going to create a simple ecommerce web application which allows users to add or remove products to the cart, and see the total price and number of items in their cart.

To prevent this blog from getting too long and to not lose focus, I'm not going to explain, in depth, the project structure and all the components inside it. However, the code for the entire project will be available here. Feel free to clone the GitHub repository and work through it on your own.

  • Configure the File structure:

Inside the src directory, create a new directory by the name of context, then create a file inside it named say CartContext.js (you can name it anything you want).

Create another directory inside src by the name of reducer and populate it with a file named CartReducer.js.

Your file structure should look something like this:

- src
    - context
        - CartContext.js
    - reducer
        - CartReducer.js
  • Create Context:

Now, let's get inside our CartContext.js file.

First, we need to create an initial state, i.e., the state of our application when our user first visits it. In our case, we would want our cart list to be empty and the total price to be zero initially, so let's do that:

const initialState = {
  items: [],
  totalPrice: 0,
}

For the next step, we need to create our context, which we can use everywhere in our application. In order to do so, we'll have to import createContext from React. createContext requires one argument, i.e., the initial state (which we defined earlier) for the context object.

Let's do that:

import { createContext } from 'react';

const initialState = {
  items: [],
  totalPrice: 0,
}

const CartContext = createContext(initialState);

Great so far!

We've created our context but to make it accessible to the components inside our application, we've to create a context provider or a wrapper which will wrap around our App component (preferably, but not necessarily).

Let's create a provider which will hold our context object and provide it to any child components that request it:

export const CartProvider = ({children}) => {

    const value = {};

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

value is everything that we want to be accessible to the children i.e., the component that we wrap our provider around. value can include data variables as well as functions.

We've exported CartProvider because we'll require it to wrap around our App component. Let's see how.

Open the index.js file located inside the root directory of your React project. Import CartProvider in it and wrap it around the App component:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { CartProvider } from './context/CartContext';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
      <CartProvider>
        <App />
      </CartProvider>
  </React.StrictMode>
);

Bingo! However, we've to go through one more step to enable the wrapped components to access our CartContext. Go back to the CartContext.js file, and define a useCart function inside it:

export const useCart = () => {
    return useContext(CartContext);
};

We've used useContext (which you'll import from React) to make our CartContext usable by the wrapped components.

Remember how I had said earlier that we can pass on data variables as well as functions through the value object to the children or the components that our CartProvider wraps around? Let's see how we can do that.

First, include this inside your CartProvider:

const [state, dispatch] = useReducer(cartReducer, initialState);

The line of code initializes a state and a dispatch function returned by the useReducer hook.

The first argument given to the useReducer hook is the reducer function, which is a pure function that takes the current state and an action object, and returns a new state. (we'll define this inside our CartReducer.js file). The second argument that it takes is the initial state of the component, which can be an object, array, or any other data type.

state can be thought of as a more complicated or bigger version of the usual state that we define using useState (see documentation for useState here). It can include states for multiple variables, i.e., total price as well as cart list.

Let's define an addToCart function which will update the cart list every time as new item is added into it before we talk about dispatch.

const addToCart = (product) => {
        const updatedCart = state.items.contact(product);
        dispatch({
            type: "ADD_TO_CART",
            payload: {
                products: updatedCart
            }
        })
    };

You must be seeing some new syntax. Let's break it down!

First, it creates a new variable called updatedCart that holds the current items in the cart (which are stored in the state.items array) and adds a new product to the cart (which is passed in as an argument to the function). It then calls the dispatch function with an object that describes the action of adding a product to the cart.

The dispatch function is a method provided by the useReducer hook that updates the state of the application. The object passed to the dispatch function has a type property that describes the action being performed (in this case, “ADD_TO_CART”), and a payload property that contains any data related to the action (in this case, the updated cart).

At this point, your CartProvider function should look like this:

export const CartProvider = ({children}) => {

    const [state, dispatch] = useReducer(cartReducer, initialState);

    const addToCart = () => {
        const updatedCart = state.items.contact(product);
        dispatch({
            type: "ADD_TO_CART",
            payload: {
                products: updatedCart
            }
        })
    };

    const value = {
        total: state.total,
        cartList: state.items,
        addToCart
    };

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

See, how we've passed in the data variables as well as the function to the value object.

Let's move on to the CartReducer file now.

  • Define the Reducer function:

Open the CartReducer.js file inside the reducer directory. Inside it, we initialize a reducer function.

export const cartReducer = (state, action) => {
    const {type, payload} = action;

    switch(type){
        case "":
            return 
        default:
            throw new Error("No case found!")
    }
}

A reducer function, which in this case is cartReducer takes in the current state and an action object as arguments.

We extract the type and payload properties from the action object using object destructuring.

type determines the kind of action that will be performed whereas the payload holds the data through which the state will be updated or modified.

We then use a switch statement to determine which action to perform based on the value of the type property. If the switch statement does not match any of the cases, the function throws an error.

Remember, how we had added the type “ADD_TO_CART” inside our addToCart function? This means that whenever the type is “ADD_TO_CART”, the addToCart function will be executed and the value returned from it will be used to update the state accordingly. Similarly, we can set types for other functions as well.

Remember, how we had added the payload which contained the updated cart list inside our addToCart function? Let's add the appropriate return value:

export const cartReducer = (state, action) => {
    const {type, payload} = action;

    switch(type){
        case "ADD_TO_CART":
            return {...state, cartList: payload.products}
        default:
            throw new Error("No case found!")
    }
}

You might be wondering why we are exporting this cartReducer function. I hope that you noticed how we passed in cartReducer to the useReducer hook inside our CartContext.js file!

That cartReducer was this very function that we had imported from our cartReducer.js file. So, it is recommended to define a reducer function before we import it inside our context provider to avoid any errors.

And that's pretty much it. You can add more data variables to the state object and define more function inside your CartContext.js file. You can create more than one context inside your context directory as well. You just have to follow these common steps, and you'll be good to go!

  • Use the Context:

We can use the context inside any component by utilizing the useCart function and using object destructing to extract any or all of the values available through the context like:

import { useCart } from "../context/CartContext"

const { total, items, addToCart, removeFromCart } = useCart();

Conclusion:

By combining context and reducer, you can create a powerful state management system for your React application. With the knowledge gained from this guide, you should be able to start using context and reducer in your own projects, and create more efficient, maintainable, and scalable applications.

Learn more about Context and Reducer in React here.

Thanks for reading!