Tutorial: Build a shopping cart
If you haven't already, try to solve it yourself first before reading the solution!
Step 1: Structuring the component tree
Before we start, it helps to design the high level structure of the component tree.
For this, letâs start from the mockup design and just circle the parts that could become components:
- weâll need a
Product
component, to display the details of each product - weâll need a
ProductsList
component, to display all products - weâll need a
ShoppingCart
component to display the shopping cart
Step 2: Structuring the state
In terms of state, weâll need to track:
- the list of products available for sale
- whether a product was added to the cart or not
- the quantity bought
There are two ways to go about this:
-
Option 1: Keep a list of all products; for each product, track its quantity; if quantity is greater than 0, it means the product is in the cart
const products = [ { id: 1, name: âCoffee mugâ, quantity: 3 }, { id: 2, name: âTea pot }]
-
Option 2: Keep two separate lists: one for products available and one for shopping cart items; for each shopping cart item, track its quantity.
const products = [ { id: 1, name: âCoffee mugâ }, { id: 2, name: âTea pot }] const cart = [ { productId: 1, quantity: 3 } ]
Both options are good for simple applications, but if we had a real production application, the first option wouldnât be possible - we couldnât possibly fetch a list of all the products on the client side! The shop could have thousands of products, and we would need to filter through all of them on every action, to check which ones were added to the cart and which not.
So the best option in this case is Option 2: keep two separate lists, one for the products and one for the cart.
How would this actually look in practice?
products
list - a simple array that holds the products as they were returned by the APIcart
list - an array with an entry for each product added to the cart; we can track the productid
, so we can look the details up based on the id, and itsquantity
In terms of sharing the state, the ShoppingCart
will need to have access to the products
, to get the details of each product. We can use the strategy of lifting the state up to make sure both products and cart are defined in the closest common parent of ShoppingCart
and ProductsList
components.
Step 3: Putting it all together - creating the components
Letâs break down the existing code into the three components we discussed earlier: Product
, ProductsList
and ShoppingCart
. This is also a good time to tweak the layout and set up a basic two column layout. Once weâre done, the app should look like below:
First, the Product component:
// components/Product.jsx
const Product = ({ product }) => {
return (
<div
style={{ border: "1px solid lightgray", padding: "5px", margin: "5px" }}
>
<p>
{product.name} ( price: ${product.price})
</p>
<img src={`images/${product.image}`} width="200px" />
</div>
);
};
export default Product;
Then, the list:
// components/ProductsList.jsx
import Product from "./Product";
const ProductsList = ({ products }) => {
return (
<div style={{ display: "flex", flexWrap: "wrap" }}>
{products.map((product) => (
<Product key={product.id} product={product} />
))}
</div>
);
};
export default ProductsList;
Then, the shopping cart - basically empty for now:
// components/ShoppingCart.jsx
const ShoppingCart = ({ cart }) => {
return (
<div>
<h3>Shopping cart</h3>
<p>You have {cart.length} products in your cart.</p>
</div>
);
};
export default ShoppingCart;
And lastly, updating the App.jsx
with the above components and a bit of layout.
We already have a state variable for products, so letâs add one more for the cart as well.
// App.jsx
import { useEffect, useState } from "react";
import { fetchProducts } from "./api-mock/products-api";
import ProductsList from "./components/ProductsList";
import ShoppingCart from "./components/ShoppingCart";
const App = () => {
const [products, setProducts] = useState([]);
const [cart, setCart] = useState([]);
useEffect(() => {
fetchProducts().then((products) => {
console.log("Fetched the products", products);
setProducts(products);
});
});
return (
<div>
<h1>Coffee shop</h1>
<div style={{ display: "flex" }}>
<div style={{ width: "70%", padding: "10px" }}>
<ProductsList products={products} />
</div>
<div
style={{
width: "30%",
backgroundColor: "lightskyblue",
padding: "10px",
}}
>
<ShoppingCart cart={cart} />
</div>
</div>
</div>
);
};
export default App;
Step 4: Displaying the shopping cart
In the previous step, we created a component for the shopping cart, but it doesnât really do anything yet.
The official React docs recommend first building the component tree all the way down with just displaying the data, and later coming back to add interactivity. Letâs follow this way of thinking, and letâs just imagine we already have a cart with some products.
Hereâs an example cart we can use to test the display:
const cart = [
{ productId: 1, quantity: 2 },
{ productId: 3, quantity: 1 },
{ productId: 5, quantity: 5 }
]
As you can see, we donât have access to the product name or price - this is information will retrieved from the list of products, based on the product id. The data structure of the products returned by the mock backend is this one.
What we actually want to display in the end in the cart is:
- the product name
- the quantity
- the unit price
- the total price for the product
- the total price for the entire cart
We will need to derive all of the above by combining the cart
with the products
:
const cartWithDetails = cart.map((item) => {
const productDetails = products.find(
(product) => product.id === item.productId
);
return {
...item,
productName: productDetails.name,
productPrice: productDetails.price,
totalProductPrice: productDetails.price * item.quantity,
};
});
To calculate the total price of the cart, we can use the Array.reduce
function:
const totalCartPrice = cartWithDetails.reduce((total, currentItem) => {
return total + currentItem.totalProductPrice;
}, 0);
The completed component could look as below:
const ShoppingCart = ({ cart, products }) => {
if (!products.length) {
return;
}
const cartWithDetails = cart.map((item) => {
const productDetails = products.find(
(product) => product.id === item.productId
);
return {
...item,
productName: productDetails.name,
productPrice: productDetails.price,
totalProductPrice: productDetails.price * item.quantity,
};
});
const totalCartPrice = cartWithDetails.reduce((total, currentItem) => {
return total + currentItem.totalProductPrice;
}, 0);
return (
<div>
<h3>Shopping cart</h3>
<p>You have {cart.length} products in your cart.</p>
<table>
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{cartWithDetails.map((item) => (
<tr key={item.productId}>
<td style={{ paddingRight: "20px" }}>
{item.productName} (${item.productPrice})
</td>
<td>{item.quantity}</td>
<td>${item.totalProductPrice}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th colSpan={3} style={{ textAlign: "right", paddingTop: "10px" }}>
Total price: ${totalCartPrice}
</th>
</tr>
</tfoot>
</table>
</div>
);
};
export default ShoppingCart;
Step 5: Polishing
What we created so far works well, but there are some loose ends to tie up:
- the price formatting is not consistent, sometimes the app shows decimals, sometimes it doesnât
- there is no loading indicator when the products are first loading, causing a âflashâ of content
Letâs fix these.
Formatting the price
Right now, when displaying the price, we just output it directly:
<p>price: ${product.price}</p>
The currency is just concatenated to the price everywhere this is used. Letâs replace this with a formatCurrency
function that we can use everywhere weâre displaying the price (đĄ alternatively, we could even make a <Currency>
component).
<p>price: {formatPrice(product.price)}</p>
There are two things this method should do:
-
display the currency - which we can hardcode to USD for now
-
format the number to two decimals - we can do this using Number.toFixed method
export const formatPrice = (price) => { return `$${price.toFixed(2)}`; };
Adding loading and error handling when fetching the products
Easiest way to add loading and error handling logic is to use a data fetching library like react-query
, that abstracts the details for us and just gives us access to state variables that we can use to update the UI.
npm i react-query
Then, weâll need to configure it - we need to create a queryClient
- this will be the object that tracks all requests in the app and caches them - and weâll need a way to make this queryClient
available to all of our components - by wrapping our entire app in the QueryProvider
component react-query gives us.
// index.js
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { QueryClient, QueryClientProvider } from "react-query";
// Create a client
const queryClient = new QueryClient();
const container = document.getElementById("root");
const root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
The query client is very basic for now, but it also accepts configuration that allows you to tweak request settings throughout the app - like how long to cache the query results for and so on.
Last, letâs replace the product fetching logic to use react query and handle errors and loading:
// App.jsx
const {
data: products,
isFetching,
error,
} = useQuery("get-products", () => fetchProducts());
Step 6: Adding interactivity
Last but not least, letâs hook everything together!
Weâll add an âAdd to cartâ button next to each product and whenever a product gets added to the cart, its quantity will increase.
This part of architecting the app corresponds to adding inverse data flow - that is, having components at the bottom of the tree change the state that is tracked at the top of the tree.
Whenever the âAdd to cartâ button is clicked, we can configure the <Product/>
component to notify its parents using a function passed as a prop - onAddToCart
.
const Product = ({ product, onAddToCart }) => {
return (
..
<button onClick={() => onAddToCart(product)}>Add to cart</button>
);
};
Weâll then need to pass this prop up all throughout the component tree:
const ProductsList = ({ products, onAddToCart }) => {
return (
..
{products.map((product) => (
<Product key={product.id} product={product} onAddToCart={onAddToCart} />
))}
);
};
Lastly, in the App.jsx
, we can actually handle the event:
const handleAddToCart = (product) => {
const isProductInCart = Boolean(
cart.find((item) => item.productId === product.id)
);
if (isProductInCart) {
// If product is already in the cart, increase its quantity
setCart(
cart.map((item) => {
if (item.productId === product.id) {
return {
...item,
quantity: item.quantity + 1,
};
}
return item;
})
);
} else {
// If product is not already in the cart, add it
setCart([...cart, { productId: product.id, quantity: 1 }]);
}
};
...
<ProductsList products={products} onAddToCart={handleAddToCart} />
Conclusion
This tutorial went through building a simple shopping cart app.
As you noticed, there are a lot of features missing - for example removing items from the cart!
Fell free to continue building upon this exercise as you see fit!
Share your thoughts or feedback in the comments below.
Member discussion