8 min read

Tutorial: Build a shopping cart using react-query

💡
This is a detailed tutorial that solves the Build a shopping cart with react-query exercise.
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:

component-tree-breakdown

  • 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 API
  • cart list - an array with an entry for each product added to the cart; we can track the product id, so we can look the details up based on the id, and its quantity

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:

Screenshot2023-02-01at083113

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.

Screenshot2023-02-01at090224

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.