React E-commerce Product Listing
Table of Contents + −
This is the project where everything you have practiced finally turns into a real shop. If you just finished the React Expense Tracker, you already know state and lists, so now we build a full product page with filtering, search, sorting, and a working cart.
🎯 What We’re Building
We are building a product listing page like the ones you see on Amazon or any online store. The shopper lands on a grid of products. They can filter, search, sort, and drop items into a cart that keeps a running count and total.
Here is what the finished app can do:
- Show a responsive grid of product cards with image, title, price, and a star rating.
- Filter products by category with a row of buttons.
- Sort products by price, low to high or high to low.
- Search products by typing in a search box.
- Add products to a cart, with a live cart count and a running total price.
- Handle the loading state and the empty state, so the page never looks broken.
The key idea you will learn here is derived state. We never store a “filtered list” in state. We keep the raw products plus a few small filter values, then we calculate the visible list while rendering. That keeps everything in sync.
Here is roughly what the finished screen looks like.
Output
ShopEasy 🛒 Cart (3) $129.97
[ All ] [ Electronics ] [ Clothing ] [ Home ] Sort: [ Price: Low → High ▾ ]Search: [ headphones________ ]
┌───────────────┐ ┌───────────────┐ ┌───────────────┐│ [ image ] │ │ [ image ] │ │ [ image ] ││ Wireless... │ │ Cotton Tee │ │ Desk Lamp ││ ★ 4.5 │ │ ★ 4.2 │ │ ★ 4.8 ││ $59.99 │ │ $19.99 │ │ $24.99 ││ [ Add to Cart]│ │ [ Add to Cart]│ │ [ Add to Cart]│└───────────────┘ └───────────────┘ └───────────────┘🗂️ Project Setup
We will use Vite because it starts fast and needs almost no config. Let’s create the project and clean it up.
-
Create the project with Vite. Run this in your terminal. Pick the React template, not React + TypeScript, so the code matches this page.
Terminal window npm create vite@latest ecommerce-store -- --template reactcd ecommerce-storenpm install -
Start the dev server so you can see changes as you save.
Terminal window npm run dev -
Clean out the starter files. Open
src/App.cssand delete everything inside it. We will add our own styles later. -
Create the files we need. Inside
src, make acomponentsfolder and adatafolder. We will build the app piece by piece into these.
Here is the file structure we are aiming for. Each piece has one clear job.
ecommerce-store/├── src/│ ├── components/│ │ ├── ProductCard.jsx # one product card│ │ ├── ProductGrid.jsx # the grid of cards│ │ ├── Filters.jsx # category + sort + search│ │ └── CartBadge.jsx # cart count and total in the header│ ├── data/│ │ └── products.js # our product list│ ├── App.jsx # holds all the state│ ├── App.css # all the styles│ └── main.jsx # entry file (Vite made this)└── index.htmlA quick word on where state lives. The cart and the filter values are needed by several components at once. So we keep them in App.jsx, the common parent, and pass them down as props. That is the pattern called lifting state up. The parent owns the data, the children just receive it and report back.
🧱 The Product Data
Before any UI, we need products to show. You could fetch these from the free Fake Store API, and we will show how at the end. For now we use a local array, so the app works with no network and you can read exactly what each field is.
Put this in src/data/products.js.
export const products = [ { id: 1, title: "Wireless Headphones", price: 59.99, category: "Electronics", rating: 4.5, image: "https://picsum.photos/seed/headphones/300/200", }, { id: 2, title: "Cotton T-Shirt", price: 19.99, category: "Clothing", rating: 4.2, image: "https://picsum.photos/seed/tshirt/300/200", }, { id: 3, title: "Desk Lamp", price: 24.99, category: "Home", rating: 4.8, image: "https://picsum.photos/seed/lamp/300/200", }, { id: 4, title: "Bluetooth Speaker", price: 39.99, category: "Electronics", rating: 4.1, image: "https://picsum.photos/seed/speaker/300/200", }, { id: 5, title: "Denim Jacket", price: 79.99, category: "Clothing", rating: 4.6, image: "https://picsum.photos/seed/jacket/300/200", }, { id: 6, title: "Ceramic Mug", price: 12.99, category: "Home", rating: 4.3, image: "https://picsum.photos/seed/mug/300/200", }, { id: 7, title: "Smart Watch", price: 129.99, category: "Electronics", rating: 4.7, image: "https://picsum.photos/seed/watch/300/200", }, { id: 8, title: "Throw Pillow", price: 16.99, category: "Home", rating: 4.0, image: "https://picsum.photos/seed/pillow/300/200", },];Here is what matters about this data:
- Every product has a unique
id. We use it as the Reactkeyand to track cart items, so it must be unique. categoryis a plain string. Our filter buttons will match against it.priceis a number, not a string like"59.99". That lets us sort and add prices without surprises.imageuses picsum.photos, a free placeholder image service, so the cards have real pictures without you finding any.
The id being unique is the part people get wrong. If two products share an id, React’s list rendering gets confused and the cart cannot tell them apart.
🃏 The Product Card
A card shows one product. We build the smallest piece first, then arrange many of them in a grid. The card takes a product and an onAddToCart function as props.
Create src/components/ProductCard.jsx.
function ProductCard({ product, onAddToCart }) { return ( <div className="product-card"> <img src={product.image} alt={product.title} className="product-image" /> <div className="product-info"> <h3 className="product-title">{product.title}</h3> <p className="product-category">{product.category}</p> <p className="product-rating">★ {product.rating.toFixed(1)}</p> <p className="product-price">${product.price.toFixed(2)}</p> <button className="add-button" onClick={() => onAddToCart(product)}> Add to Cart </button> </div> </div> );}
export default ProductCard;Reading the card top to bottom:
- The
imgusesproduct.imagefor the source andproduct.titlefor thealttext, which screen readers read out and which shows if the image fails. product.rating.toFixed(1)forces one decimal place, so a4shows as4.0and the cards line up neatly.product.price.toFixed(2)gives us two decimals, the way money is shown.- The button calls
onAddToCart(product)on click. We wrap it in an arrow function so it only runs on the click, not while rendering.
Why does the card take a function as a prop instead of changing the cart itself? Because the cart does not live here. It lives up in App. The card just says “this product was clicked” and hands the whole product object back up. That is lifting state up in action.
🔲 The Product Grid
The grid takes the list of products to show and lays out one ProductCard per item. It also handles the empty case, when a search or filter matches nothing.
Create src/components/ProductGrid.jsx.
import ProductCard from "./ProductCard";
function ProductGrid({ products, onAddToCart }) { if (products.length === 0) { return <p className="empty-message">No products match your search.</p>; }
return ( <div className="product-grid"> {products.map((product) => ( <ProductCard key={product.id} product={product} onAddToCart={onAddToCart} /> ))} </div> );}
export default ProductGrid;What is going on here:
- The early
returnhandles the empty state. If the list is empty we show a friendly message instead of a blank space. products.map(...)turns each product object into aProductCard. This is list rendering, the standard way React draws a collection.- Every card gets
key={product.id}. The key is how React tells the items apart when the list changes. Without a stable key, React can re-use the wrong DOM nodes and you get strange bugs. - We pass
onAddToCartstraight through to each card. The grid does not use it, it just forwards it down.
A common temptation is to use the array index as the key, like key={index}. Avoid that when the list can be filtered or sorted, because the index of an item changes as the list changes, which is exactly when keys need to stay stable.
🔎 The Filters: Category, Sort, and Search
Now the controls. This one component holds the category buttons, the sort dropdown, and the search box. It does not store any of those values itself. The parent owns them and passes the current value plus a setter for each. That keeps a single source of truth.
Create src/components/Filters.jsx.
function Filters({ categories, activeCategory, onCategoryChange, sortOrder, onSortChange, search, onSearchChange,}) { return ( <div className="filters"> <div className="category-buttons"> {categories.map((category) => ( <button key={category} className={ category === activeCategory ? "category-button active" : "category-button" } onClick={() => onCategoryChange(category)} > {category} </button> ))} </div>
<div className="filter-controls"> <input type="text" className="search-input" placeholder="Search products..." value={search} onChange={(e) => onSearchChange(e.target.value)} />
<select className="sort-select" value={sortOrder} onChange={(e) => onSortChange(e.target.value)} > <option value="default">Sort by</option> <option value="low-high">Price: Low → High</option> <option value="high-low">Price: High → Low</option> </select> </div> </div> );}
export default Filters;The important parts of this component:
categories.map(...)draws one button per category. The parent builds that list for us, so adding a product in a new category makes a new button appear automatically.- The active button gets an extra
activeclass. We pick the class with a simple check: is this button’s category the one that is currently selected? That gives us the highlighted look. - The search box and the sort dropdown are controlled inputs. Their
valuecomes from props and every change calls a setter from the parent. React state is the single source of truth, the input just reflects it. e.target.valueis the current text or the chosen option. We hand that straight up to the parent’s setter.
The reason this component is “dumb” on purpose is that it owns nothing. It shows what it is given and reports changes. So the filtering logic stays in one place, the parent, and never gets out of sync.
🛒 The Cart Badge
The header needs to show how many items are in the cart and the total price. This little component just displays two numbers it receives.
Create src/components/CartBadge.jsx.
function CartBadge({ count, total }) { return ( <div className="cart-badge"> <span className="cart-icon">🛒</span> <span className="cart-text">Cart ({count})</span> <span className="cart-total">${total.toFixed(2)}</span> </div> );}
export default CartBadge;Notes on this one:
- It takes
countandtotalas props and shows them. It has no state of its own. total.toFixed(2)keeps the money formatted with two decimals.- The
countin parentheses is the classic cart number you see on every shop. We will compute it in the parent from the cart contents.
This is a display-only component. Keeping pieces like this small and free of logic makes the whole app easier to read and to test.
🧠 App: Wiring State and Derived Lists
Now the brain of the app. App.jsx owns all the state: the filter values and the cart. It calculates the visible product list from that state, computes the cart numbers, and hands everything down.
Open src/App.jsx and replace it with this.
import { useState } from "react";import { products as allProducts } from "./data/products";import Filters from "./components/Filters";import ProductGrid from "./components/ProductGrid";import CartBadge from "./components/CartBadge";import "./App.css";
function App() { const [activeCategory, setActiveCategory] = useState("All"); const [sortOrder, setSortOrder] = useState("default"); const [search, setSearch] = useState(""); const [cart, setCart] = useState([]);
// Build the category list from the products, with "All" in front. const categories = ["All", ...new Set(allProducts.map((p) => p.category))];
// Derived list: filter, search, then sort. Never stored in state. const visibleProducts = allProducts .filter((p) => activeCategory === "All" || p.category === activeCategory) .filter((p) => p.title.toLowerCase().includes(search.toLowerCase())) .sort((a, b) => { if (sortOrder === "low-high") return a.price - b.price; if (sortOrder === "high-low") return b.price - a.price; return 0; });
// Add a product to the cart. If it is already there, raise its quantity. function handleAddToCart(product) { setCart((prevCart) => { const existing = prevCart.find((item) => item.id === product.id); if (existing) { return prevCart.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); } return [...prevCart, { ...product, quantity: 1 }]; }); }
// Cart numbers, derived from the cart array. const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0); const cartTotal = cart.reduce( (sum, item) => sum + item.price * item.quantity, 0 );
return ( <div className="app"> <header className="app-header"> <h1 className="logo">ShopEasy</h1> <CartBadge count={cartCount} total={cartTotal} /> </header>
<Filters categories={categories} activeCategory={activeCategory} onCategoryChange={setActiveCategory} sortOrder={sortOrder} onSortChange={setSortOrder} search={search} onSearchChange={setSearch} />
<ProductGrid products={visibleProducts} onAddToCart={handleAddToCart} /> </div> );}
export default App;This file does a lot, so let’s walk it slowly.
The state at the top:
activeCategoryholds which category button is selected. It starts as"All".sortOrderholds the sort choice. It starts as"default", which means no sorting.searchholds the current text in the search box.cartis an array of items the shopper added. Each item is a product plus aquantity.
The categories line is a neat trick:
allProducts.map((p) => p.category)gives us every category, with repeats.new Set(...)drops the repeats, because a Set only keeps unique values.- The spread
[...]turns that Set back into an array, and we put"All"at the front.
So the buttons always match the real data. Add a “Books” product later and a Books button shows up on its own.
The visibleProducts chain is the heart of the app. We start from the raw list and shape it step by step:
- First
filterkeeps products in the chosen category, or all of them when “All” is selected. - Second
filterkeeps products whose title contains the search text. We lowercase both sides so the search ignores capital letters. - Then
sortorders by price.a.price - b.pricesorts low to high, the reverse sorts high to low, and0means leave the order alone.
Notice we calculate this fresh on every render. We do not keep visibleProducts in state. That is derived state, and it is why the list is never stale. Change any filter and the next render simply recomputes the list.
The handleAddToCart function updates the cart immutably:
- We call
setCartwith a function that receives the previous cart, which is the safe way to update based on current state. - We check if the product is already in the cart with
find. - If it is there, we
mapover the cart and bump just that item’s quantity, returning a new object for it with the spread...item. - If it is new, we return a new array with the product added and
quantity: 1.
Every branch returns a brand new array. We never push into the old one. React only re-renders when it sees a new reference, so mutating the old array would not update the screen.
The cart numbers are derived too:
cartCountadds up every item’s quantity withreduce. Two of one product counts as two.cartTotaladds upprice * quantityfor each item, which is the running total.
Both recompute on every render from the single cart array, so they can never disagree with what is actually in the cart.
🎨 Styling
Now we make it look like a real shop. Good styling is a big part of why a project feels finished, so we will cover the grid, the cards, the cart badge, and the active filter button.
Put all of this in src/App.css.
* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: system-ui, -apple-system, sans-serif; background: #f4f5f7; color: #1a1a1a;}
.app { max-width: 1100px; margin: 0 auto; padding: 24px;}
/* Header */.app-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px;}
.logo { font-size: 1.8rem; color: #2563eb;}
/* Cart badge */.cart-badge { display: flex; align-items: center; gap: 8px; background: #2563eb; color: #fff; padding: 8px 16px; border-radius: 999px; font-weight: 600;}
.cart-total { background: rgba(255, 255, 255, 0.2); padding: 2px 10px; border-radius: 999px;}
/* Filters */.filters { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px;}
.category-buttons { display: flex; flex-wrap: wrap; gap: 8px;}
.category-button { border: 1px solid #d1d5db; background: #fff; padding: 8px 16px; border-radius: 999px; cursor: pointer; font-size: 0.9rem; transition: all 0.15s ease;}
.category-button:hover { border-color: #2563eb;}
.category-button.active { background: #2563eb; color: #fff; border-color: #2563eb;}
.filter-controls { display: flex; gap: 8px;}
.search-input,.sort-select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.9rem;}
.search-input:focus,.sort-select:focus { outline: none; border-color: #2563eb;}
/* Product grid */.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px;}
.product-card { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); display: flex; flex-direction: column; transition: transform 0.15s ease, box-shadow 0.15s ease;}
.product-card:hover { transform: translateY(-4px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);}
.product-image { width: 100%; height: 180px; object-fit: cover;}
.product-info { padding: 16px; display: flex; flex-direction: column; gap: 6px; flex: 1;}
.product-title { font-size: 1rem;}
.product-category { font-size: 0.8rem; color: #6b7280;}
.product-rating { font-size: 0.85rem; color: #f59e0b;}
.product-price { font-size: 1.2rem; font-weight: 700; color: #111827; margin-top: auto;}
.add-button { margin-top: 8px; border: none; background: #2563eb; color: #fff; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 600; transition: background 0.15s ease;}
.add-button:hover { background: #1d4ed8;}
/* Empty state */.empty-message { text-align: center; padding: 40px; color: #6b7280; font-size: 1.1rem;}The styling choices that do the heavy lifting:
- The grid uses
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)). This is what makes it responsive with no media queries. Each card wants at least 220px, and the browser fits as many columns as the screen allows. Narrow phone gets one column, wide screen gets four. - The cart badge is a rounded pill,
border-radius: 999px, with the total in a lighter inner pill. That is the familiar shop-cart look. - The active category button flips to the blue background, so the shopper always sees which filter is on.
- The card lifts slightly on hover with
transform: translateY(-4px)and a deeper shadow, which makes it feel clickable and alive. margin-top: autoon the price pushes it and the button to the bottom of each card, so cards with shorter titles still line their prices up.
🧩 The Complete Code
Here is every file in one place, ready to copy and run.
src/data/products.js:
export const products = [ { id: 1, title: "Wireless Headphones", price: 59.99, category: "Electronics", rating: 4.5, image: "https://picsum.photos/seed/headphones/300/200", }, { id: 2, title: "Cotton T-Shirt", price: 19.99, category: "Clothing", rating: 4.2, image: "https://picsum.photos/seed/tshirt/300/200", }, { id: 3, title: "Desk Lamp", price: 24.99, category: "Home", rating: 4.8, image: "https://picsum.photos/seed/lamp/300/200", }, { id: 4, title: "Bluetooth Speaker", price: 39.99, category: "Electronics", rating: 4.1, image: "https://picsum.photos/seed/speaker/300/200", }, { id: 5, title: "Denim Jacket", price: 79.99, category: "Clothing", rating: 4.6, image: "https://picsum.photos/seed/jacket/300/200", }, { id: 6, title: "Ceramic Mug", price: 12.99, category: "Home", rating: 4.3, image: "https://picsum.photos/seed/mug/300/200", }, { id: 7, title: "Smart Watch", price: 129.99, category: "Electronics", rating: 4.7, image: "https://picsum.photos/seed/watch/300/200", }, { id: 8, title: "Throw Pillow", price: 16.99, category: "Home", rating: 4.0, image: "https://picsum.photos/seed/pillow/300/200", },];src/components/ProductCard.jsx:
function ProductCard({ product, onAddToCart }) { return ( <div className="product-card"> <img src={product.image} alt={product.title} className="product-image" /> <div className="product-info"> <h3 className="product-title">{product.title}</h3> <p className="product-category">{product.category}</p> <p className="product-rating">★ {product.rating.toFixed(1)}</p> <p className="product-price">${product.price.toFixed(2)}</p> <button className="add-button" onClick={() => onAddToCart(product)}> Add to Cart </button> </div> </div> );}
export default ProductCard;src/components/ProductGrid.jsx:
import ProductCard from "./ProductCard";
function ProductGrid({ products, onAddToCart }) { if (products.length === 0) { return <p className="empty-message">No products match your search.</p>; }
return ( <div className="product-grid"> {products.map((product) => ( <ProductCard key={product.id} product={product} onAddToCart={onAddToCart} /> ))} </div> );}
export default ProductGrid;src/components/Filters.jsx:
function Filters({ categories, activeCategory, onCategoryChange, sortOrder, onSortChange, search, onSearchChange,}) { return ( <div className="filters"> <div className="category-buttons"> {categories.map((category) => ( <button key={category} className={ category === activeCategory ? "category-button active" : "category-button" } onClick={() => onCategoryChange(category)} > {category} </button> ))} </div>
<div className="filter-controls"> <input type="text" className="search-input" placeholder="Search products..." value={search} onChange={(e) => onSearchChange(e.target.value)} />
<select className="sort-select" value={sortOrder} onChange={(e) => onSortChange(e.target.value)} > <option value="default">Sort by</option> <option value="low-high">Price: Low → High</option> <option value="high-low">Price: High → Low</option> </select> </div> </div> );}
export default Filters;src/components/CartBadge.jsx:
function CartBadge({ count, total }) { return ( <div className="cart-badge"> <span className="cart-icon">🛒</span> <span className="cart-text">Cart ({count})</span> <span className="cart-total">${total.toFixed(2)}</span> </div> );}
export default CartBadge;src/App.jsx:
import { useState } from "react";import { products as allProducts } from "./data/products";import Filters from "./components/Filters";import ProductGrid from "./components/ProductGrid";import CartBadge from "./components/CartBadge";import "./App.css";
function App() { const [activeCategory, setActiveCategory] = useState("All"); const [sortOrder, setSortOrder] = useState("default"); const [search, setSearch] = useState(""); const [cart, setCart] = useState([]);
const categories = ["All", ...new Set(allProducts.map((p) => p.category))];
const visibleProducts = allProducts .filter((p) => activeCategory === "All" || p.category === activeCategory) .filter((p) => p.title.toLowerCase().includes(search.toLowerCase())) .sort((a, b) => { if (sortOrder === "low-high") return a.price - b.price; if (sortOrder === "high-low") return b.price - a.price; return 0; });
function handleAddToCart(product) { setCart((prevCart) => { const existing = prevCart.find((item) => item.id === product.id); if (existing) { return prevCart.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); } return [...prevCart, { ...product, quantity: 1 }]; }); }
const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0); const cartTotal = cart.reduce( (sum, item) => sum + item.price * item.quantity, 0 );
return ( <div className="app"> <header className="app-header"> <h1 className="logo">ShopEasy</h1> <CartBadge count={cartCount} total={cartTotal} /> </header>
<Filters categories={categories} activeCategory={activeCategory} onCategoryChange={setActiveCategory} sortOrder={sortOrder} onSortChange={setSortOrder} search={search} onSearchChange={setSearch} />
<ProductGrid products={visibleProducts} onAddToCart={handleAddToCart} /> </div> );}
export default App;The src/main.jsx file is the entry file Vite created for you. You do not need to change it, but here it is so the listing is complete.
import { StrictMode } from "react";import { createRoot } from "react-dom/client";import App from "./App.jsx";
createRoot(document.getElementById("root")).render( <StrictMode> <App /> </StrictMode>);Drop src/App.css from the Styling section above next to these files and the project is done.
▶️ Run It
If the dev server is still running, the page already updated. If not, start it from the project folder.
npm run devOpen the address Vite prints, usually http://localhost:5173. You should see the shop. Click a category to filter. Type in the search box to narrow it down. Pick a sort option to reorder by price. Click Add to Cart and watch the count and total climb in the header.
Output
ShopEasy 🛒 Cart (2) $79.98
[ All ] [ Electronics ] [ Clothing ] [ Home ] Search: [______] [ Sort by ▾ ]
Wireless Headphones $59.99 ★ 4.5 [ Add to Cart ]Bluetooth Speaker $39.99 ★ 4.1 [ Add to Cart ]Smart Watch $129.99 ★ 4.7 [ Add to Cart ]🔌 Using the Fake Store API Instead
Want real product data from the internet? Swap the local array for the free Fake Store API. The trick is that fetching takes time, so we have to handle the loading state and any error. We do that with useEffect, which runs the fetch once after the first render.
Here is the top of App.jsx rewritten to load from the API. The middle of the file, your categories, visibleProducts, handleAddToCart, and cart totals, is exactly the same as the main App.jsx above. Only the data source and the loading and error guards change.
import { useState, useEffect } from "react";
function App() { const [allProducts, setAllProducts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { fetch("https://fakestoreapi.com/products") .then((res) => res.json()) .then((data) => { setAllProducts(data); setLoading(false); }) .catch(() => { setError("Could not load products."); setLoading(false); }); }, []);
// Keep categories, visibleProducts, handleAddToCart, cartCount, and // cartTotal exactly as they are in the main App.jsx above.
if (loading) return <p className="empty-message">Loading products...</p>; if (error) return <p className="empty-message">{error}</p>;
// ...then return the same page JSX (header, Filters, ProductGrid) as above.}The parts that matter when fetching:
allProductsstarts as an empty array, not the local list. We fill it once the data arrives.loadingstartstrueso the page shows “Loading…” until the response comes back.- The empty dependency array
[]onuseEffectmeans the fetch runs only once, after the first render, not on every render. catchsets an error message, so a failed network does not leave the user staring at a blank screen.
A few field names differ in the Fake Store API, so you have to adjust the card to match:
- The rating is an object, so read it as
product.rating.rate, notproduct.rating. - The category names come back in lowercase and are different from ours. The API uses
electronics,jewelery,men's clothing, andwomen's clothing, not our capitalizedElectronics,Clothing, andHome. So your filter buttons will show the API’s categories, not the ones from the local data. That is expected, the buttons just reflect whatever data you give them. - The image field is
product.image, which already matches ours, so no change there.
Reading someone else’s data and adapting your component to its shape is a normal part of the job, so this mismatch is good practice.
⚠️ Common Mistakes
A few traps catch almost everyone the first time. Watch for these.
-
Mutating the cart array. Pushing into the existing array does not trigger a re-render.
// ❌ Mutates the old array, screen does not updatecart.push(product);setCart(cart);// ✅ Build a new array, React sees a new referencesetCart((prev) => [...prev, { ...product, quantity: 1 }]); -
Storing the filtered list in state. It goes stale the moment a filter changes.
// ❌ Now you have to keep this in sync by handconst [visible, setVisible] = useState(products);// ✅ Just compute it while renderingconst visible = products.filter(/* ... */); -
Using the array index as the key. It breaks when the list is sorted or filtered.
// ❌ Index changes when the list reorders{products.map((p, i) => <ProductCard key={i} ... />)}// ✅ A stable, unique id{products.map((p) => <ProductCard key={p.id} ... />)} -
Comparing search text case-sensitively. Typing “headphones” should still match “Headphones”. Lowercase both sides with
toLowerCase()before comparing.
✅ Best Practices
The habits this project follows that you can carry into bigger apps:
- Keep one source of truth. State lives in
App, and children get values and setters as props. - Derive, do not duplicate. The visible list and the cart totals are calculated, never stored.
- Update state immutably. Every cart change returns a new array or new objects.
- Split the UI into small, focused components. A card, a grid, a filter bar, a badge, each with one job.
- Always give list items a stable, unique
key.
When the app grows and prop passing gets deep, the natural next step is to move the cart into Context, so any component can read and update it without threading props through every level. For a page this size, lifting state up is the cleaner choice.
🚀 Extend It / Make It Your Own
Once it works, try adding to it. These are good next features.
- A cart drawer that slides out and lists the items, with remove and quantity buttons.
- Save the cart to
localStorageso it survives a page refresh. - A “minimum rating” filter, the same derived-state pattern with one more
filterstep. - Pagination or a “Load more” button when the product list gets long.
- A product detail page using React Router, opened when a card is clicked.
- Move the cart into Context and add a
useCarthook so any component can use it.
🧩 What You’ve Learned
Look back at what you just built. That is a real, working shop page.
- ✅ Built a responsive product grid with CSS Grid and no media queries.
- ✅ Filtered, searched, and sorted with derived state instead of extra state.
- ✅ Rendered lists correctly with stable, unique keys.
- ✅ Lifted cart state into the parent and updated it immutably.
- ✅ Computed the cart count and total from a single cart array.
- ✅ Handled the empty state, and the loading and error states with the API version.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why do we compute visibleProducts during render instead of storing it in state?
Why: The visible list is derived from the raw products plus the filter values. Recomputing it every render means it can never disagree with the current filters.
- 2
What is the problem with using the array index as a list key here?
Why: When the list reorders, item indexes shift. React needs a key that stays attached to the same item, which is what the unique product id gives us.
- 3
Why does handleAddToCart return a new array instead of pushing into the old one?
Why: React compares references to decide whether to re-render. A mutated array is the same reference, so the UI would not update. Returning a new array fixes that.
- 4
Why is cart state kept in App rather than inside CartBadge or ProductCard?
Why: The cart is read by the badge and changed from the cards. Lifting it to the common parent gives one source of truth that all of them share.
🚀 What’s Next?
You can build a complete, interactive page now, so the next step is to show off your work with a polished personal site. React Portfolio Website