Building a Shopping Cart with React and Local Storage

·16 min read

blog/building-shopping-cart-reat-localstorage

In this blog post, we’ll explore how to create a shopping cart component using React and local storage. We’ll cover the following topics:

  1. Setting up the shopping cart context and provider
  2. Creating custom hooks for managing the cart state
  3. Integrating local storage to persist cart data
  4. Building the cart overlay component
  5. Handling cart item management (adding, removing, updating quantities)

By the end of this post, you’ll have a solid understanding of how to implement a shopping cart in your React applications using context, hooks, and local storage. Let’s dive in.

1. Shopping Cart Context and Provider

In React, the Context API is a way to share data between components without having to pass the data through every level of the component tree. It’s like a global store that any component can access directly.

In our shopping cart implementation, we’ll be using two separate contexts to manage different aspects of the cart functionality:

  1. ShoppingCartContext: This context will store the actual cart data, such as the cart items, and provide functions to add, remove, update, and clear items from the cart.
  2. CartOverlayContext: This context will manage the visibility of the cart overlay, which opens from the side on any page of the store.

The following diagram illustrates the component hierarchy and the flow of data between the ShoppingCartProvider, ShoppingCartContext, and CartOverlayContext:

sc-provider

Let’s start by creating the CartOverlayContext:

import { createContext, useContext } from "react";
import { noop } from "@/helpers/utils";

export const useCartOverlay = () => useContext(CartOverlayContext);

const CartOverlayContext = createContext({
  isCartOpen: false,
  openCart: noop,
  closeCart: noop,
});

export default CartOverlayContext;

The CartOverlayContext provides the following:

  • isCartOpen: A boolean value indicating whether the cart overlay is currently open or closed.
  • openCart: A function to open the cart overlay.
  • closeCart: A function to close the cart overlay.

Next, let’s create the ShoppingCartContext:

import { createContext } from "react";
import { Product, Variant } from "@/types/ui_types";
import { noop } from "@/helpers/utils";

export type CartItem = {
  product: Product;
  quantity: number;
  variant: Variant | null;
};

type ShoppingCartContextType = {
  cartItems: CartItem[];
  addToCart: (
    product: Product,
    quantity: number,
    variant: Variant | null,
  ) => void;
  removeFromCart: (productId: string, variantId: string | null) => void;
  updateCartItemQuantity: (
    productId: string,
    variantId: string | null,
    quantity: number,
  ) => void;
  clearCart: () => void;
};

const ShoppingCartContext = createContext<ShoppingCartContextType>({
  cartItems: [],
  addToCart: noop,
  removeFromCart: noop,
  updateCartItemQuantity: noop,
  clearCart: noop,
});

export default ShoppingCartContext;

The ShoppingCartContext defines the following:

  • cartItems: An array of CartItem objects representing the items in the cart.
  • addToCart: A function to add a product to the cart or increase its quantity if it already exists.
  • removeFromCart: A function to remove a product from the cart based on its ID and variant ID.
  • updateCartItemQuantity: A function to update the quantity of a cart item based on its product ID and variant ID.
  • clearCart: A function to clear all items from the cart.

To make the context available to the components, we need a provider. The provider is a special component that wraps the components that need access to the context. It’s like a delivery service that brings the context data to the components.

We create a provider component called ShoppingCartProvider:

import React, { useEffect, useState } from "react";
import { Product, Variant } from "@/types/ui_types";
import ShoppingCartContext from "./shoppingCartContext";
import CartOverlayContext from "./cartOverlayContext";

const ShoppingCartProvider: React.FC<Props> = ({ children, storeKey }) => {
  const [cartItems, setCartItems] = useState<CartItem[]>([]);
  const [cartLoaded, setCartLoaded] = useState(false);
  const [isCartOpen, setIsCartOpen] = useState(false);
  const [isClearCartRequested, setIsClearCartRequested] = useState(false);

  // Cart management functions

  const addToCart = (
    product: Product,
    quantity: number,
    variant: Variant | null,
  ) => {
    setCartItems((prevCartItems) => {
      const existingItem = prevCartItems.find(
        (item) =>
          item.product.id === product.id && item.variant?.id === variant?.id,
      );

      if (existingItem) {
        return prevCartItems.map((item) =>
          item.product.id === product.id && item.variant?.id === variant?.id
            ? { ...item, quantity: item.quantity + quantity }
            : item,
        );
      } else {
        return [...prevCartItems, { product, quantity, variant }];
      }
    });

    openCart(); // Cart Overlay
  };

  const removeFromCart = (productId: string, variantId: string | null) => {
    setCartItems(
      cartItems.filter(
        (item) =>
          item.product.id !== productId || item.variant?.id !== variantId,
      ),
    );
  };

  const updateCartItemQuantity = (
    productId: string,
    variantId: string | null,
    quantity: number,
  ) => {
    const existingCartItemIndex = cartItems.findIndex(
      (item) => item.product.id === productId && item.variant?.id === variantId,
    );

    if (existingCartItemIndex >= 0) {
      const updatedCartItems = [...cartItems];
      updatedCartItems[existingCartItemIndex].quantity = quantity;
      setCartItems(updatedCartItems);
    }
  };

  const clearCart = () => {
    setIsClearCartRequested(true);
  };

  const openCart = () => {
    setIsCartOpen(true);
  };

  const closeCart = () => {
    setIsCartOpen(false);
  };

  // Provide context values

  const cartOverlayContextValue = {
    isCartOpen,
    openCart,
    closeCart,
  };

  const shoppingCartContextValue: ShoppingCartContextType = {
    cartItems,
    addToCart,
    removeFromCart,
    updateCartItemQuantity,
    clearCart,
  };

  return (
    <ShoppingCartContext.Provider value={shoppingCartContextValue}>
      <CartOverlayContext.Provider value={cartOverlayContextValue}>
        {children}
      </CartOverlayContext.Provider>
    </ShoppingCartContext.Provider>
  );
};

export default ShoppingCartProvider;

The ShoppingCartProvider manages the state for both the cart items (cartItems) and the cart overlay visibility (isCartOpen). It provides the context values to the child components using the ShoppingCartContext.Provider and CartOverlayContext.Provider.

2. Custom Hooks for Managing Cart State

To make it easier for components to access the cart state and functions, we can create custom hooks. Let’s create two custom hooks: one for managing the cart overlay and another for managing the cart items.

import { useContext } from "react";
import ShoppingCartContext from "./shoppingCartContext";
import CartOverlayContext from "./cartOverlayContext";

export const useCartOverlay = () => useContext(CartOverlayContext);
export const useCart = () => useContext(ShoppingCartContext);

The useCartOverlay hook allows components to access the CartOverlayContext, which provides the isCartOpen state and the openCart and closeCart functions.

The useCart hook allows components to access the ShoppingCartContext, which provides the cartItems state and the cart management functions (addToCart, removeFromCart, updateCartItemQuantity, clearCart).

By using these custom hooks, components can easily interact with the cart state and functionality without the need for prop drilling.

The following diagram shows how the custom hooks (useCartOverlay and useCart) interact with the context and the components that use them:

hooks

For example, to open the cart overlay when a new item is added to the cart, you can use the useCartOverlay hook in the addToCart function:

const addToCart = (
  product: Product,
  quantity: number,
  variant: Variant | null,
) => {
  // Add the item to the cart
  setCartItems((prevCartItems) => {
    // ...
  });

  // Open the cart overlay
  openCart();
};

And in the component that renders the cart overlay, you can use the useCartOverlay hook to conditionally render the overlay based on the isCartOpen state:

const CartOverlay: React.FC = () => {
  const { isCartOpen, closeCart } = useCartOverlay();

  if (!isCartOpen) {
    return null;
  }

  return (
    // Render the cart overlay
  );
};

By separating the concerns of cart data management and cart overlay visibility into two separate contexts and providing custom hooks to access them, we achieve a more modular and maintainable architecture for our shopping cart implementation.

In the next section, we can explore how to integrate local storage to persist the cart data across page refreshes and sessions.

3. Integrating Local Storage to Persist Cart Data

Local storage is a web storage mechanism that allows websites to store key-value pairs in the user’s browser. It provides a way to persist data even after the user closes the browser or navigates away from the website. By storing the cart data in local storage, we can ensure that the user’s cart items are preserved across sessions, providing a seamless shopping experience.

To integrate local storage with our shopping cart provider, we can use the useEffect hook to retrieve cart data from local storage when the component mounts and update local storage whenever the cart data changes.

The following diagram demonstrates how the cart data is stored in local storage and retrieved when the component mounts:

sd-ls

Here’s an example of how to implement local storage extending the ShoppingCartProvider component:

const ShoppingCartProvider: React.FC<Props> = ({ children, storeKey }) => {
  // other state properties
  const [cartItems, setCartItems] = useState<CartItem[]>([]);


  // Add to the shopping cart provider to support local storage save and load

  // Load cart data from local storage on component mount
  useEffect(() => {
    const storedCartItems = localStorage.getItem(`cart_${storeKey}`);
    if (storedCartItems) {
      setCartItems(JSON.parse(storedCartItems));
    }
  }, [storeKey]);

  // Update local storage when cart items change
  useEffect(() => {
    localStorage.setItem(`cart_${storeKey}`, JSON.stringify(cartItems));
  }, [cartItems, storeKey]);

  // Cart management functions (addToCart, removeFromCart, updateCartItemQuantity, clearCart)
  // functions defined above
}

In the first useEffect hook, we retrieve the cart data from local storage when the component mounts. We use the storeKey prop to create a unique key for storing the cart data, allowing multiple instances of the shopping cart to exist independently. If there are stored cart items, we parse the JSON data and set it as the initial state of cartItems.

In the second useEffect hook, we update the local storage whenever the cartItems state changes. We stringify the cartItems array and store it in local storage using the storeKey. This ensures that the cart data persists across page refreshes and navigation.

By wrapping the local storage functionality within the ShoppingCartProvider component, we encapsulate the cart data persistence logic and provide a seamless integration with the rest of the shopping cart functionality.

It’s important to note that local storage is not just beneficial for desktop websites but also for mobile web applications. On mobile devices, local storage works similarly, allowing users to persist their cart data across sessions and providing a consistent shopping experience even if they switch between different pages or close the browser.

With local storage integration, our shopping cart becomes more robust and user-friendly, offering a personalized experience to users across different sessions.

4. Building the Cart Overlay Component

Now let’s build the cart overlay component that will display the cart items and allow users to manage their cart. We’ll create a single CartOverlay component that combines the functionality of the header, footer, and cart items display.

import React, { useEffect } from "react";
import Image from "next/image";
import { useCart } from "@/hooks/shoppingCartProvider";

type Props = {
  isCartOpen: boolean;
  closeCart: () => void;
};

const CartOverlay: React.FC<Props> = ({ isCartOpen, closeCart }) => {
  const { cartItems, removeFromCart, updateCartItemQuantity } = useCart();

  const handleRemoveFromCart = (productId: string, variantId: string | null) => {
    removeFromCart(productId, variantId);
  };

  const handleDecreaseQuantity = (item: CartItem) => {
    if (item.quantity > 1) {
      updateCartItemQuantity(
        item.product.id,
        item.variant?.id ?? null,
        item.quantity - 1,
      );
    }
  };

  const handleIncreaseQuantity = (item: CartItem) => {
    updateCartItemQuantity(
      item.product.id,
      item.variant?.id ?? null,
      item.quantity + 1,
    );
  };

  const subtotal = cartItems
    .reduce((total, item) => total + item.product.price * item.quantity, 0)
    .toFixed(2);

  useEffect(() => {
    if (isCartOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "auto";
    }
    return () => {
      document.body.style.overflow = "auto";
    };
  }, [isCartOpen]);

  return (
    <div
      className={`fixed inset-0 z-50 overflow-hidden ${
        isCartOpen ? "pointer-events-auto" : "pointer-events-none"
      }`}
    >
      <div
        className={`absolute inset-0 bg-black bg-opacity-50 transition-opacity ${
          isCartOpen ? "opacity-100" : "opacity-0"
        }`}
        onClick={() => closeCart()}
      />
      <div
        className={`absolute inset-y-0 right-0 max-w-full flex transform transition-transform duration-300 ${
          isCartOpen ? "translate-x-0" : "translate-x-full"
        }`}
      >
        <div className="w-screen max-w-md">
          <div className="h-full flex flex-col bg-white shadow-xl">
            {/* Cart Overlay Header */}
            <div className="flex justify-between items-center px-4 py-3 border-b">
              <h2 className="text-lg font-semibold">Shopping Cart</h2>
              <button
                onClick={closeCart}
                className="text-gray-500 hover:text-gray-600"
              >
                <span className="sr-only">Close cart</span>
                <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            </div>

            {/* Cart Items */}
            <div className="flex-1 overflow-y-auto">
              {cartItems.length === 0 ? (
                <div className="flex flex-col items-center justify-center h-full text-center">
                  <svg className="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
                  </svg>
                  <h3 className="mt-2 text-sm font-medium text-gray-900">Your cart is empty</h3>
                  <p className="mt-1 text-sm text-gray-500">Add items to your cart to get started.</p>
                </div>
              ) : (
                <div className="px-4 divide-y divide-gray-200">
                  {cartItems.map((item) => (
                    <div key={`${item.product.id}-${item.variant?.id}`} className="py-4">
                      <div className="flex items-start">
                        <div className="flex-shrink-0">
                          <Image
                            src={item.variant?.image || item.product.images[0]}
                            alt={item.product.name}
                            className="w-16 h-16 rounded-md object-cover"
                            width={64}
                            height={64}
                          />
                        </div>
                        <div className="ml-4">
                          <h3 className="text-sm font-medium text-gray-900">{item.product.name}</h3>
                          {item.variant && (
                            <p className="mt-1 text-sm text-gray-500">
                              {item.variant.color}
                              {item.variant.size && ` - ${item.variant.size}`}
                            </p>
                          )}
                          <p className="mt-1 text-sm font-medium text-gray-900">${item.product.price.toFixed(2)}</p>
                        </div>
                      </div>
                      <div className="mt-4 flex items-center">
                        <button
                          onClick={() => handleDecreaseQuantity(item)}
                          className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
                        >
                          -
                        </button>
                        <span className="mx-2 text-sm text-gray-500">{item.quantity}</span>
                        <button
                          onClick={() => handleIncreaseQuantity(item)}
                          className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
                        >
                          +
                        </button>
                        <button
                          onClick={() => handleRemoveFromCart(item.product.id, item.variant?.id ?? null)}
                          className="ml-4 text-sm font-medium text-red-600 hover:text-red-500"
                        >
                          Remove
                        </button>
                      </div>
                    </div>
                  ))}
                </div>
              )}
            </div>

            {/* Cart Overlay Footer */}
            {cartItems.length > 0 && (
              <div className="px-4 py-6 border-t">
                <div className="flex justify-between text-base font-medium text-gray-900">
                  <p>Subtotal</p>
                  <p>${subtotal}</p>
                </div>
                <p className="mt-0.5 text-sm text-gray-500">Shipping and taxes calculated at checkout.</p>
                <div className="mt-6">
                  <button
                    className="w-full px-6 py-3 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700"
                  >
                    Checkout
                  </button>
                </div>
                <div className="mt-6 flex justify-center text-sm text-center text-gray-500">
                  <p>
                    or{" "}
                    <button
                      type="button"
                      className="text-indigo-600 font-medium hover:text-indigo-500"
                      onClick={closeCart}
                    >
                      Continue Shopping<span aria-hidden="true"> &rarr;</span>
                    </button>
                  </p>
                </div>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
};

export default CartOverlay;

In the CartOverlay component, we use the useCart custom hook to access the cartItems, removeFromCart, and updateCartItemQuantity functions.

The following diagram illustrates the flow of adding an item to the cart from the ProductCard component using the addToCart function from the useCart hook:

add-to-cart

The component renders the cart overlay content based on the isCartOpen prop, which determines whether the overlay is visible or not. It consists of three main sections: the header, the cart items, and the footer.

The header displays the title “Shopping Cart” and a close button to allow users to close the cart overlay.

The cart items section renders the list of items in the cart. If the cart is empty, it displays a message indicating that the cart is empty. Otherwise, it maps over the cartItems array and renders each item with its image, name, price, and quantity. Users can increase or decrease the quantity of each item using the ”+” and ”-” buttons, and they can remove an item from the cart using the “Remove” button.

The footer section displays the subtotal of the cart items and a “Checkout” button to proceed to the checkout process. It also provides a “Continue Shopping” button to allow users to close the cart overlay and continue browsing the website.

The component uses the useEffect hook to control the overflow style of the document.body based on the isCartOpen state. When the cart overlay is open, it sets overflow to "hidden" to prevent scrolling on the main content, and when the cart overlay is closed, it sets overflow back to "auto".

This simplified version of the CartOverlay component provides a comprehensive example of how to build a cart overlay with functionality for managing cart items, displaying the subtotal, and proceeding to checkout. You can customize the styling and layout to match your application’s design.

Here’s a visual representation of how the cart overlay would look like empty:

cart-empty

Here’s a visual representation of how the cart overlay would look like with a product:

cart-preview

The cart overlay displays the list of cart items, allowing users to manage quantities, remove items, and proceed to checkout. The header shows the title “Shopping Cart” and provides a close button, while the footer displays the subtotal and the “Checkout” and “Continue Shopping” buttons.

5. Handling Cart Item Management

In the previous section, we saw how the CartOverlay component handles the management of cart items using the removeFromCart and updateCartItemQuantity functions provided by the useCart custom hook.

Now, let’s see how we can add items to the cart from our website. Here’s an example of a ProductCard component that allows users to add products to the cart:

import React from "react";
import Image from "next/image";
import { useCart } from "@/hooks/shoppingCartProvider";

type Props = {
  product: Product;
};

const ProductCard: React.FC<Props> = ({ product }) => {
  const { addToCart } = useCart();

  const handleAddToCart = () => {
    addToCart(product, 1, null);
  };

  return (
    <div className="bg-white shadow-md rounded-lg p-4">
      <div className="relative pb-48 overflow-hidden">
        <Image
          src={product.images[0]}
          alt={product.name}
          className="absolute inset-0 h-full w-full object-cover"
          width={300}
          height={300}
        />
      </div>
      <div className="mt-4">
        <h3 className="text-lg font-medium text-gray-900">{product.name}</h3>
        <p className="mt-1 text-sm text-gray-500">${product.price.toFixed(2)}</p>
        <button
          onClick={handleAddToCart}
          className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
        >
          Add to Cart
        </button>
      </div>
    </div>
  );
};

export default ProductCard;

In the ProductCard component, we use the useCart custom hook to access the addToCart function. We define a handleAddToCart function that calls addToCart with the current product, a quantity of 1, and a variant value of null (assuming the product doesn’t have variants in this example).

The component renders the product image, name, price, and an “Add to Cart” button. When the user clicks the “Add to Cart” button, it triggers the handleAddToCart function, which adds the product to the cart using the addToCart function from the useCart hook.

Finally, to make the ShoppingCartProvider available to our entire application, we need to wrap our application with the provider in the _app.tsx file:

import { ShoppingCartProvider } from "@/hooks/shoppingCartProvider";

function MyApp({ Component, pageProps }) {
  return (
    <ShoppingCartProvider>
      <Component {...pageProps} />
    </ShoppingCartProvider>
  );
}

export default MyApp;

By wrapping our application with the ShoppingCartProvider, we ensure that all components have access to the cart state and functions provided by the context.

And there you have it! We’ve covered the main components and logic for creating a shopping cart using React and the Context API. By utilizing custom hooks and context, we can create a modular and efficient shopping cart component that can be easily integrated into any e-commerce application.

If you’re interested in enhancing your React application with user authentication, check out the related post on Authentication in Next.js 14 with NextAuth.js. It covers how to implement secure user authentication, which could be a valuable addition to your e-commerce site with a shopping cart.

I hope this blog post has been informative and helpful in understanding how to build a shopping cart with React. Feel free to explore the code further and adapt it to your specific needs.

Related PostsDevelopment, Javascript, React

Profile

I'm a software developer and consultant. I help companies build great products. Contact me by email.

Get my new content delivered straight to your inbox. No spam, ever.