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.
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.
.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; }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>
);
}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.
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>
);
}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.
.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; }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>
);
}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:
setTimeoutstands in for a payment API call, and thestatusstate 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
localStoragewith the hook from the to-do project. - Convert it to TypeScript with
Product,CartLineandCartActiontypes. - 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:
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:
<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?