cart state management presented in a diagram

Photo Credit: image by the author

Build a React cart system with the Context API and useReducer hook

November 05, 2023 React

Explore React’s powerful state management capabilities by building a typical shopping cart. Discover:

  • How to handle shared state

  • Manage cart items

  • Update the user interface seamlessly

We will do that all without external state management libraries, only with React’s Context API and the useReducer hook.

In the previous post, Demystifying React state management: A comprehensive guide, I wrote about the different types of state in React apps. I also explored the tools that React exposes to manage state.

That is good as a quick reference, but in order to better understand those tools, let's put them into practice while implementing a generic cart functionality.

Table of contents

  • Cart requirements

  • Cart state design

  • Cart implementation with React, the Context API and useReducer hook

Cart requirements

Our application will have only two views:

  • Product List

  • Cart

It will also fulfill the following functional requirements:

  • View a list of products

  • Add a product to the cart

  • View the cart

  • Increase/decrease the quantity of an item

  • Remove products from the cart

  • The total price gets updated automatically

  • Update the number badge in the cart icon in the navigation whenever a product is added or removed from the cart

The wireframes of the two views:

Cart functionality wireframe
Cart functionality wireframe — image by the author

Cart state design

We clearly see that this application will need to manage some form of state. We will definitely need the following data:

  • The list of products

  • The list of products in the cart + quantity of each product

  • The total cost of the products

To help us understand where the state belongs, and how should it be managed, we can always ask the following questions:

  • Is it local or shared state?

  • Is it ephemeral or a persistent state?

There will also be additional questions depending on the use case and business requirements that can affect our decisions. For our application we will make the following assumptions:

Product list view:

  • We want it always to contain the freshest list, so It’s ephemeral — every time we refresh, or enter the product page again, we want to fetch the newest data. For the sake of simplicity, I will be using a list of mock products so we can skip the loading/network handling.

  • We only need the list of products on the Products List view, so it’s local — we will not be sharing this list with the rest of the app

  • Caution: If the application had more views or functionalities, it would be very possible that we would need to store the products in a piece of shared state. Our assumptions are always based on what we know at the moment.

Cart view:

  • It will contain a list of products and their quantities. This state can be updated and accessed by multiple components and views (Product List, Navigation, Cart), so it is a shared state.

  • The user might not want to make a checkout immediately, and it is likely he will return later to our app to complete the purchase, so this state is persistent.

  • The cost of the product x quantity can be calculated on the fly, so we will not store it anywhere.

  • Like with the partial cost of product x quantity, this can be calculated on the fly, and we only need it on the Cart view, so we will not store it anywhere.

Cart shared state
Cart shared state — image by the author

Cart implementation with React, the Context API, and the useReducer hook

You can find the final code in this repository — https://github.com/TWasilonek/react-cart-state-management.

In a professional environment, it is now the norm to use TypeScript, so I will also use it for all examples.

We have two parts, the first one is the cartContext.

import {
  Dispatch,
  FC,
  ReactNode,
  createContext,
  useContext,
  useReducer,
} from "react"
import { CartAction, CartState, cartReducer } from "./cartReducer"

const initialState = {
  cartItems: [],
}

// Each context should be atomic, and responsible for only one thing
export const CartContext = createContext<CartState>(initialState)
export const CartDispatchContext = createContext<Dispatch<CartAction> | null>(
  null
)

type CartProviderProps = {
  children: ReactNode
  cartValue?: CartState
}

// Our app-wide CartProvider encapsulates all Cart-related context
// and the reducer setup
export const CartProvider: FC<CartProviderProps> = ({
  children,
  cartValue = initialState,
}) => {
  const [state, dispatch] = useReducer(cartReducer, cartValue)
  return (
    <CartContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartContext.Provider>
  )
}

export function useCart() {
  return useContext(CartContext)
}

export function useCartDispatch() {
  return useContext(CartDispatchContext)
}

The main takeaways:

  • We have atomic context providers for the cart data and the cart dispatch function.

  • We expose one provider CartProvider, that wraps the atomic context providers and sets their values to the state and dispatch function from the cartReducer.

  • Following the usual frontend convention, we create convenience custom hooks useCart and useCartDispatch that are syntactic sugar over calling useContext directly. This is easier to remember and requires fewer imports, when in use.

The cart reducer is implemented as follows:

import { CartItem } from "../types"

export type CartState = {
  cartItems: CartItem[]
}

export type CartAction = {
  type: string
  payload: CartItem
}

export const CART_ACTIONS = {
  ADD_ITEM: "ADD_ITEM",
  REMOVE_ITEM: "REMOVE_ITEM",
  INCREMENT_QUANTITY: "INCREMENT_QUANTITY",
  DECREMENT_QUANTITY: "DECREMENT_QUANTITY",
}

const addItem = (state: CartState, item: CartItem): CartState => {
  const newCartItems = [...state.cartItems]
  const itemIndex = newCartItems.findIndex(
    cartItem => cartItem.product.id === item.product.id
  )

  // if item is already in the cart, don't update the state
  if (itemIndex > -1) {
    return { ...state }
  }

  newCartItems.push({ ...item, quantity: 1 })
  return { ...state, cartItems: newCartItems }
}

const removeItem = (state: CartState, item: CartItem): CartState => {
  const newCartItems = [...state.cartItems]
  const itemIndex = newCartItems.findIndex(
    cartItem => cartItem.product.id === item.product.id
  )

  // if item is not in cart, don't update the state
  if (itemIndex === -1) {
    return { ...state }
  }

  newCartItems.splice(itemIndex, 1)
  return { ...state, cartItems: newCartItems }
}

const incrementQuantity = (state: CartState, item: CartItem): CartState => {
  const newCartItems = [...state.cartItems]
  const itemIndex = newCartItems.findIndex(
    cartItem => cartItem.product.id === item.product.id
  )

  // if item is not in cart, don't update the state
  if (itemIndex === -1) {
    return { ...state }
  }

  // ugly, because we didn't deeply cloned the newCartItems array
  const newItem = { ...newCartItems[itemIndex] }
  newItem.quantity++
  newCartItems[itemIndex] = newItem

  return { ...state, cartItems: newCartItems }
}

const decrementQuantity = (state: CartState, item: CartItem): CartState => {
  const newCartItems = [...state.cartItems]
  const itemIndex = newCartItems.findIndex(
    cartItem => cartItem.product.id === item.product.id
  )

  // if item is not in cart,don't update the state
  if (itemIndex === -1) {
    return { ...state }
  }

  const newItem = { ...newCartItems[itemIndex] }
  newItem.quantity--
  newCartItems[itemIndex] = newItem

  // if the decremented item quantity is 0, remove the item
  if (newCartItems[itemIndex].quantity === 0) {
    newCartItems.splice(itemIndex, 1)
  }

  return { ...state, cartItems: newCartItems }
}

export const cartReducer = (
  state: CartState,
  action: CartAction
): CartState => {
  switch (action.type) {
    case CART_ACTIONS.ADD_ITEM:
      return addItem(state, action.payload)
    case CART_ACTIONS.REMOVE_ITEM:
      return removeItem(state, action.payload)
    case CART_ACTIONS.INCREMENT_QUANTITY:
      return incrementQuantity(state, action.payload)
    case CART_ACTIONS.DECREMENT_QUANTITY:
      return decrementQuantity(state, action.payload)
    default:
      return state
  }
}

It follows the usual reducer pattern:

Type Definitions and Constants:

  • The code defines TypeScript types CartState and CartAction to represent the state of the cart and actions that can be performed on it.

  • It creates a constant object called "CART_ACTION" with action type strings like "ADD_ITEM", "REMOVE_ITEM", etc.

Action Handling Functions:

  • The code includes functions like addItem, removeItem, incrementQuantity, and decrementQuantity that take the current cart state and a cart item as input.

  • These functions perform actions like adding an item, removing an item, increasing or decreasing an item’s quantity in the cart, and returning a new cart state.

Reducer Function:

  • The cartReducer function serves as a reducer for managing the cart state. It takes the current cart state and a cart action as input.

  • It uses a switch statement to determine the type of action and calls the corresponding action handling function to modify the cart state.

  • If the action type doesn’t match any of the defined cases, it returns the current cart state unchanged.

The cartReducer and cart context are the two building blocks of the shared state, now let’s use them in the app.

Setup the App and wrap it with the Cart Provider

First, let’s wrap our component tree with the CartProvider

import * as React from "react"
import { createBrowserRouter, RouterProvider } from "react-router-dom"

import { ProductsList } from "./products/ProductsList"
import { Cart } from "./cart/Cart"
import { CartProvider } from "./store/cartContext"
import { Root } from "./ui/Root"

// The RouterProvider contains our routes.
// There are only two views - ProductList and Cart.
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "/",
        element: <ProductsList />,
      },
      {
        path: "/cart",
        element: <Cart />,
      },
    ],
  },
])

const initialCartState = {
  cartItems: [],
}

function App() {
  return (
    <React.StrictMode>
      <CartProvider cartValue={initialCartState}>
        <RouterProvider router={router} />
      </CartProvider>
    </React.StrictMode>
  )
}

export default App

In the main App component:

  • We set the routes

  • We wrap our routes with the CartProvider component and pass it the initial state, which is just an empty array.

Add items to the cart in the ProductList

The Product List is just a regular view with a list of items — in our case it contains some products.

Product List view
Product List view — image by the author

And the code for the view is as follows:

import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { isInCart } from "../helpers/cartHelpers"
import { formatCurrency } from "../helpers/formatCurrency"
import { productsMock } from "../mocks/productsMock"
import { useCart, useCartDispatch } from "../store/cartContext"
import { CART_ACTIONS } from "../store/cartReducer"
import { Product } from "../types"

export const ProductsList = () => {
  const [products, setProducts] = useState<Product[]>([])
  const dispatch = useCartDispatch()
  const { cartItems } = useCart()

  useEffect(() => {
    const fetchProducts = async () => {
      // normally we would fetch products from an API
      const products = await Promise.resolve(productsMock)
      setProducts(products)
    }
    fetchProducts()
  }, [])

  const handleAddToCart = (product: Product) => {
    dispatch &&
      dispatch({
        type: CART_ACTIONS.ADD_ITEM,
        payload: {
          product,
          quantity: 1,
        },
      })
  }

  return (
    <ul className="product-list">
      {products.map(product => (
        <li key={product.id} className="product-card">
          <img
            src={product.imageUrl}
            alt={product.name}
            width="300"
            height="300"
          />
          <h3>{product.name}</h3>
          <p>{product.description}</p>
          <p>{formatCurrency(product.price)}</p>
          {isInCart(cartItems, product) ? (
            <Link to="/cart">Added to cart</Link>
          ) : (
            <button onClick={() => handleAddToCart(product)}>
              + Add to cart
            </button>
          )}
        </li>
      ))}
    </ul>
  )
}
  • We use the dispatch function from our useCartDispatch hook to send the CART_ACTIONS.ADD_ITEM action to the reducer

  • We are getting the current list of cart items from the useCart hook

  • If a product is already in the Cart, instead of displaying a button to add it, we should display a link to the Cart view.

Show the number of cart items in the Nav component

The Nav component displays the cart icon with the number of cart unique items in the cart.

Cart icon in the navigation bar
Cart icon in the navigation bar — image by the author

The code:

import { Link } from "react-router-dom"
import cartIcon from "../assets/cart.svg"
import { useCart } from "../store/cartContext"
import "../App.css"

export const Nav = () => {
  const { cartItems } = useCart()

  return (
    <nav className="nav-container">
      <ul className="nav">
        <li className="nav-item">
          <Link to="/">Products</Link>
        </li>
        <li className="nav-item">
          <Link to="/cart" className="cart-link">
            {cartItems.length > 0 && (
              <span className="cart-items-count">{cartItems.length}</span>
            )}
            <img src={cartIcon} alt="cart link" className="cart-icon" />
          </Link>
        </li>
      </ul>
    </nav>
  )
}
  • We get the cart items from the useCart hook

  • If there are more than 0 elements in the cart, we will display a small dot with the number of elements on top of the cart icon

The Cart component and view

Finally, the Cart view.

Cart view with products in cart
The cart view — image by the author

The code is a bit longer, because of how many actions we need to handle. Also the UI is slightly more complicated.

export const Cart = () => {
  const { cartItems } = useCart()
  const dispatch = useCartDispatch()

  const handleIncrementQuantity = (cartItem: CartItem) => {
    dispatch &&
      dispatch({
        type: CART_ACTIONS.INCREMENT_QUANTITY,
        payload: { ...cartItem },
      })
  }

  const handleDecrementQuantity = (cartItem: CartItem) => {
    dispatch &&
      dispatch({
        type: CART_ACTIONS.DECREMENT_QUANTITY,
        payload: { ...cartItem },
      })
  }

  const handleRemoveItem = (cartItem: CartItem) => {
    dispatch &&
      dispatch({
        type: CART_ACTIONS.REMOVE_ITEM,
        payload: {
          product: { ...cartItem.product },
          quantity: 0,
        },
      })
  }

  return (
    <>
      <h1>Cart</h1>
      <table className="cart-table">
        <thead>
          <tr>
            <th>Product</th>
            <th className="product-data-cell">Quantity</th>
            <th className="product-data-cell">Price</th>
          </tr>
        </thead>
        <tbody>
          {cartItems.map(cartItem => (
            <tr key={cartItem.product.id} className="product-row">
              <td className="product-head">
                <img
                  src={cartItem.product.imageUrl}
                  alt={cartItem.product.name}
                  className="product-image"
                  width="60"
                  height="60"
                />
                <h4>
                  <Link to="#">{cartItem.product.name}</Link>
                </h4>
              </td>
              <td className="product-data-cell">
                <div className="product-quantity">
                  <button
                    className="btn-left"
                    onClick={() => handleDecrementQuantity(cartItem)}
                  >
                    -
                  </button>
                  <span className="product-quantity_value">
                    {cartItem.quantity}
                  </span>
                  <button
                    className="btn-right"
                    onClick={() => handleIncrementQuantity(cartItem)}
                  >
                    +
                  </button>
                </div>
                <button
                  onClick={() => handleRemoveItem(cartItem)}
                  className="delete-btn"
                >
                  <img src={TrashIcon} alt="Remove" className="icon" />
                </button>
              </td>
              <td className="product-data-cell">
                {formatCurrency(cartItem.product.price * cartItem.quantity)}
              </td>
            </tr>
          ))}
          <tr>
            <td colSpan={3} className="total-cell">
              Total: &nbsp;
              <span className="total">
                {formatCurrency(getTotalPrice(cartItems))}
              </span>
            </td>
          </tr>
        </tbody>
      </table>
    </>
  )
}

To dispatch different actions to our cart reducer, we use different event handlers. Those handlers are fired by clicking on the buttons in each of the cart items.

Displaying the cart items is done in a similar way as in the ProductList:

  • Get the list with useCart hook

  • Map over the list and create an element for each of the cart items.

  • The total is calculated with a simple helper function on the fly. It will be recalculated on every re-render. This ensures that whenever our items (or their quantity) change, the totals will be re-calculated.

And this is all. If you want the full picture, feel free to inspect and clone the repository.