React E-commerce Product Listing

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.

  1. 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 react
    cd ecommerce-store
    npm install
  2. Start the dev server so you can see changes as you save.

    Terminal window
    npm run dev
  3. Clean out the starter files. Open src/App.css and delete everything inside it. We will add our own styles later.

  4. Create the files we need. Inside src, make a components folder and a data folder. 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.html

A 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 React key and to track cart items, so it must be unique.
  • category is a plain string. Our filter buttons will match against it.
  • price is a number, not a string like "59.99". That lets us sort and add prices without surprises.
  • image uses 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 img uses product.image for the source and product.title for the alt text, which screen readers read out and which shows if the image fails.
  • product.rating.toFixed(1) forces one decimal place, so a 4 shows as 4.0 and 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 return handles 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 a ProductCard. 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 onAddToCart straight 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.

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 active class. 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 value comes 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.value is 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 count and total as props and shows them. It has no state of its own.
  • total.toFixed(2) keeps the money formatted with two decimals.
  • The count in 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:

  • activeCategory holds which category button is selected. It starts as "All".
  • sortOrder holds the sort choice. It starts as "default", which means no sorting.
  • search holds the current text in the search box.
  • cart is an array of items the shopper added. Each item is a product plus a quantity.

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 filter keeps products in the chosen category, or all of them when “All” is selected.
  • Second filter keeps products whose title contains the search text. We lowercase both sides so the search ignores capital letters.
  • Then sort orders by price. a.price - b.price sorts low to high, the reverse sorts high to low, and 0 means 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 setCart with 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 map over 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:

  • cartCount adds up every item’s quantity with reduce. Two of one product counts as two.
  • cartTotal adds up price * quantity for 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: auto on 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.

Terminal window
npm run dev

Open 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:

  • allProducts starts as an empty array, not the local list. We fill it once the data arrives.
  • loading starts true so the page shows “Loading…” until the response comes back.
  • The empty dependency array [] on useEffect means the fetch runs only once, after the first render, not on every render.
  • catch sets 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, not product.rating.
  • The category names come back in lowercase and are different from ours. The API uses electronics, jewelery, men's clothing, and women's clothing, not our capitalized Electronics, Clothing, and Home. 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 update
    cart.push(product);
    setCart(cart);
    // ✅ Build a new array, React sees a new reference
    setCart((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 hand
    const [visible, setVisible] = useState(products);
    // ✅ Just compute it while rendering
    const 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 localStorage so it survives a page refresh.
  • A “minimum rating” filter, the same derived-state pattern with one more filter step.
  • 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 useCart hook 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. 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. 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. 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. 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

Share & Connect