Build a React cart system with the Context API and useReducer hook
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 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 * quantity can be calculated on the fly, so we will not store it anywhere.
- Like with the partial cost of product * 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 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 thestate
anddispatch
function from the cartReducer. - Following the usual frontend convention, we create convenience custom hooks
useCart
anduseCartDispatch
that are syntactic sugar over callinguseContext
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
andCartAction
to represent the state of the cart and actions that can be performed on it. - It creates a constant object called
CART_ACTIONS
with action type strings like"ADD_ITEM"
,"REMOVE_ITEM"
, etc.
Action Handling Functions:
- The code includes functions like
addItem
,removeItem
,incrementQuantity
, anddecrementQuantity
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.
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 ouruseCartDispatch
hook to send theCART_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.
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.
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:
<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.
Thank you for reading the full article. If you liked this post, please leave a clap and follow me for more content like this.