Skip to content
Laviyx Learn

Try

React course menu

React Project: Product Catalogue with Shopping Cart

Build an online shop front end in React: a product grid with search, category filter and sorting, a shopping cart with context and useReducer, quantity changes, totals with GST and a discount code, and a simulated checkout with loading state.

Intermediate18 min readUpdated
On this page

This project builds the front end of a small online shop: browsing products, filtering, and a cart shared across components with context and a reducer. It is a good portfolio piece because it uses the patterns real e-commerce apps use.

Step 1: The catalogue with search, filter and sort#

Search text, category and sort order are state; the visible list is derived from them during rendering.

CSS
.toolbar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 8px; font-size: 14px; }
.card .emoji { font-size: 32px; }
.card .price { font-weight: bold; }
JSX
import { useState } from "react";

const PRODUCTS = [
  { id: 1, name: "Wireless earbuds", category: "Electronics", price: 1499, emoji: "🎧" },
  { id: 2, name: "Steel water bottle", category: "Home", price: 349, emoji: "🍶" },
  { id: 3, name: "Cotton t-shirt", category: "Fashion", price: 499, emoji: "👕" },
  { id: 4, name: "Power bank 10000mAh", category: "Electronics", price: 1199, emoji: "🔋" },
  { id: 5, name: "Ceramic coffee mug", category: "Home", price: 299, emoji: "☕" },
  { id: 6, name: "Running shoes", category: "Fashion", price: 2499, emoji: "👟" },
];
const CATEGORIES = ["All", ...new Set(PRODUCTS.map((p) => p.category))];

export default function App() {
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState("All");
  const [sort, setSort] = useState("featured");

  const visible = PRODUCTS
    .filter((p) => category === "All" || p.category === category)
    .filter((p) => p.name.toLowerCase().includes(query.trim().toLowerCase()))
    .toSorted((a, b) => (sort === "low" ? a.price - b.price : sort === "high" ? b.price - a.price : a.id - b.id));

  return (
    <div>
      <div className="toolbar">
        <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search products" />
        <select value={category} onChange={(e) => setCategory(e.target.value)}>
          {CATEGORIES.map((c) => <option key={c}>{c}</option>)}
        </select>
        <select value={sort} onChange={(e) => setSort(e.target.value)}>
          <option value="featured">Featured</option>
          <option value="low">Price: low to high</option>
          <option value="high">Price: high to low</option>
        </select>
      </div>
      <p>{visible.length} of {PRODUCTS.length} products</p>
      <div className="grid">
        {visible.map((p) => (
          <div key={p.id} className="card">
            <div className="emoji">{p.emoji}</div>
            <div>{p.name}</div>
            <div className="price">₹{p.price.toLocaleString("en-IN")}</div>
          </div>
        ))}
      </div>
      {visible.length === 0 && <p>No products match your search.</p>}
    </div>
  );
}
Result

new Set(...) removes duplicate categories, and toLocaleString("en-IN") formats prices with Indian digit grouping (₹1,499).

Step 2: The cart reducer and context#

Many components need the cart: product cards (to add items), the header (to show a count), and the cart panel. So the cart state goes into a reducer, shared through context with a useCart hook.

JSX
import { createContext, useContext, useReducer } from "react";

const CartContext = createContext(null);

function cartReducer(cart, action) {
  switch (action.type) {
    case "added": {
      const line = cart.find((l) => l.id === action.product.id);
      if (line) return cart.map((l) => (l.id === line.id ? { ...l, qty: l.qty + 1 } : l));
      return [...cart, { ...action.product, qty: 1 }];
    }
    case "quantityChanged":
      return cart
        .map((l) => (l.id === action.id ? { ...l, qty: action.qty } : l))
        .filter((l) => l.qty > 0);
    case "cleared":
      return [];
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, []);
  return <CartContext value={{ cart, dispatch }}>{children}</CartContext>;
}

function useCart() {
  const value = useContext(CartContext);
  if (!value) throw new Error("useCart must be used inside <CartProvider>");
  return value;
}

function CartBadge() {
  const { cart } = useCart();
  const count = cart.reduce((n, l) => n + l.qty, 0);
  return <span>🛒 {count}</span>;
}

function AddButton({ product }) {
  const { dispatch } = useCart();
  return <button onClick={() => dispatch({ type: "added", product })}>Add {product.name}</button>;
}

export default function App() {
  return (
    <CartProvider>
      <p>Header: <CartBadge /></p>
      <AddButton product={{ id: 1, name: "Earbuds", price: 1499 }} />{" "}
      <AddButton product={{ id: 5, name: "Mug", price: 299 }} />
    </CartProvider>
  );
}
Result

CartBadge and AddButton are unrelated components in different places, yet they share the same cart without any props passed between them.

Step 3: The complete shop#

Putting it together: the catalogue, a cart panel with quantity controls, a price summary with 18% GST and a discount code (SAVE10 for 10% off), and a simulated checkout that shows a loading state.

CSS
.shop { display: grid; grid-template-columns: 1fr 220px; gap: 12px; font: 14px system-ui, sans-serif; }
@media (max-width: 520px) { .shop { grid-template-columns: 1fr; } }   /* cart below the products on phones */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
.card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 8px; }
.card .emoji { font-size: 28px; }
.cart { border: 1px solid #e5e5e5; border-radius: 8px; padding: 8px; align-self: start; }
.cart .line { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0; }
.cart .qty button { width: 22px; }
.row { display: flex; justify-content: space-between; }
.total { font-weight: bold; border-top: 1px solid #ddd; padding-top: 4px; }
.ok { color: #2f9e44; }
.err { color: #e03131; }
JSX
import { createContext, useContext, useReducer, useState } from "react";

const PRODUCTS = [
  { id: 1, name: "Wireless earbuds", price: 1499, emoji: "🎧" },
  { id: 2, name: "Steel bottle", price: 349, emoji: "🍶" },
  { id: 3, name: "Cotton t-shirt", price: 499, emoji: "👕" },
  { id: 4, name: "Power bank", price: 1199, emoji: "🔋" },
];
const GST_RATE = 0.18;
const CODES = { SAVE10: 0.1 };
const rupees = (n) =>
  `₹${n.toLocaleString("en-IN", { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;

const CartContext = createContext(null);

function cartReducer(cart, action) {
  switch (action.type) {
    case "added": {
      const line = cart.find((l) => l.id === action.product.id);
      if (line) return cart.map((l) => (l.id === line.id ? { ...l, qty: l.qty + 1 } : l));
      return [...cart, { ...action.product, qty: 1 }];
    }
    case "quantityChanged":
      return cart.map((l) => (l.id === action.id ? { ...l, qty: action.qty } : l)).filter((l) => l.qty > 0);
    case "cleared":
      return [];
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function useCart() {
  const value = useContext(CartContext);
  if (!value) throw new Error("useCart must be used inside the cart provider");
  return value;
}

function ProductGrid() {
  const { cart, dispatch } = useCart();
  return (
    <div className="grid">
      {PRODUCTS.map((p) => {
        const inCart = cart.find((l) => l.id === p.id)?.qty ?? 0;
        return (
          <div key={p.id} className="card">
            <div className="emoji">{p.emoji}</div>
            <div>{p.name}</div>
            <div><strong>{rupees(p.price)}</strong></div>
            <button onClick={() => dispatch({ type: "added", product: p })}>
              {inCart ? `Add another (${inCart})` : "Add to cart"}
            </button>
          </div>
        );
      })}
    </div>
  );
}

function CartPanel() {
  const { cart, dispatch } = useCart();
  const [code, setCode] = useState("");
  const [applied, setApplied] = useState(null);
  const [status, setStatus] = useState("idle");      // idle | paying | done

  const subtotal = cart.reduce((sum, l) => sum + l.price * l.qty, 0);
  const discount = applied ? subtotal * CODES[applied] : 0;
  const gst = (subtotal - discount) * GST_RATE;
  const total = subtotal - discount + gst;

  function applyCode() {
    const upper = code.trim().toUpperCase();
    setApplied(CODES[upper] ? upper : "invalid");
  }

  function checkout() {
    setStatus("paying");
    setTimeout(() => {                                // stands in for a real payment API call
      dispatch({ type: "cleared" });
      setApplied(null);
      setCode("");
      setStatus("done");
    }, 1500);
  }

  if (status === "done") {
    return (
      <div className="cart">
        <p className="ok">✔ Order placed! (simulated)</p>
        <button onClick={() => setStatus("idle")}>Continue shopping</button>
      </div>
    );
  }

  if (cart.length === 0) return <div className="cart">Your cart is empty.</div>;

  const validCode = applied && applied !== "invalid";
  return (
    <div className="cart">
      <strong>Cart</strong>
      {cart.map((l) => (
        <div key={l.id} className="line">
          <span>{l.emoji} {l.name}</span>
          <span className="qty">
            <button onClick={() => dispatch({ type: "quantityChanged", id: l.id, qty: l.qty - 1 })}>−</button>
            {" "}{l.qty}{" "}
            <button onClick={() => dispatch({ type: "quantityChanged", id: l.id, qty: l.qty + 1 })}>+</button>
          </span>
        </div>
      ))}
      <div style={{ margin: "6px 0" }}>
        <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Discount code" size={10} />{" "}
        <button onClick={applyCode}>Apply</button>
        {applied === "invalid" && <div className="err">Invalid code</div>}
        {validCode && <div className="ok">{applied} applied</div>}
      </div>
      <div className="row"><span>Subtotal</span><span>{rupees(subtotal)}</span></div>
      {validCode && <div className="row"><span>Discount</span><span>−{rupees(discount)}</span></div>}
      <div className="row"><span>GST 18%</span><span>{rupees(gst)}</span></div>
      <div className="row total"><span>Total</span><span>{rupees(total)}</span></div>
      <button onClick={checkout} disabled={status === "paying"} style={{ marginTop: 6, width: "100%" }}>
        {status === "paying" ? "Processing…" : "Checkout"}
      </button>
    </div>
  );
}

export default function App() {
  const [cart, dispatch] = useReducer(cartReducer, []);
  return (
    <CartContext value={{ cart, dispatch }}>
      <div className="shop">
        <ProductGrid />
        <CartPanel />
      </div>
    </CartContext>
  );
}
Result

Example: one pair of earbuds (₹1,499) and two bottles (₹698) make a subtotal of ₹2,197. With SAVE10, the discount is ₹219.70, GST is 18% of ₹1,977.30, which is ₹355.91 (rounded), and the total is ₹2,333.21.

How it fits together:

  • State placement: the cart is shared, so it lives in context; the discount code and checkout status only matter to CartPanel, so they are local state there.
  • Derived totals: subtotal, discount, GST and total are calculated on every render from the cart, never stored.
  • Checkout: setTimeout stands in for a payment API call, and the status state drives the "Processing…" and success views. A real shop sends the order to a server, which must recalculate prices itself rather than trust totals sent from the browser.

Ideas to extend it#

  • Load products from an API with loading and error states.
  • Use React Router for product detail and cart pages.
  • Persist the cart in localStorage with the hook from the to-do project.
  • Convert it to TypeScript with Product, CartLine and CartAction types.
  • Write tests for the reducer and the cart panel with Testing Library.

Practice exercise

Add free delivery to the final shop: orders with a subtotal (after discount) under ₹999 pay ₹49 delivery; at ₹999 or more, delivery is free. Show the delivery line in the summary and include it in the total.

Show hint

Calculate const afterDiscount = subtotal - discount; const delivery = afterDiscount >= 999 ? 0 : 49; and add delivery to the total. Show "Free" when it is 0.

Show solution

In CartPanel, replace the total calculation with:

JSX
const afterDiscount = subtotal - discount;
const delivery = afterDiscount >= 999 ? 0 : 49;
const gst = afterDiscount * GST_RATE;
const total = afterDiscount + gst + delivery;

and add a line to the summary:

JSX
<div className="row"><span>Delivery</span><span>{delivery === 0 ? "Free" : rupees(delivery)}</span></div>

Finished the course? Test yourself with the React interview questions.

Finished this lesson?