Welcome back, aspiring React wizard! In our journey to mastery, we’ve explored many fundamental concepts, from the basics of components and props to advanced hooks, state management, and routing. Now, it’s time to bring these pieces together to construct something truly practical and visually engaging: a feature-rich e-commerce product page.
This chapter is designed to be a significant hands-on project. You’ll learn how to integrate dynamic routing to display specific product details, manage complex component state for interactive elements like image carousels and quantity selectors, and simulate asynchronous data fetching to populate your page. We’ll focus on building a robust, modular, and user-friendly product display that mimics real-world e-commerce applications. This is where your understanding of React truly solidifies, as you’ll see how interconnected all the concepts we’ve covered really are. Get ready to build a substantial piece of a modern web application!
Prerequisites:
Before we dive in, make sure you’re comfortable with:
- React Components and Props: Creating functional components and passing data down.
- State Hook (
useState): Managing local component state. - Effect Hook (
useEffect): Performing side effects like data fetching. react-router-domBasics: Setting up routes and usinguseParamsandLink.- Conditional Rendering: Displaying different UI based on data availability or loading states.
- Basic JavaScript Array Methods:
find(),map().
Core Concepts: Architecting Your Product Page
Building a complex UI like an e-commerce product page requires careful planning and a good understanding of component composition. We want to avoid a single, monolithic component that does everything. Instead, we’ll break it down into smaller, focused components, each responsible for a specific part of the UI or functionality.
1. Product Data Structure
Before we write any React code, let’s think about the data we’ll be displaying. A typical product might have an ID, name, description, price, a list of images, available sizes/colors, and maybe a rating. For simplicity, we’ll use a mock JSON structure.
// Example of a product data structure
{
"id": "prod-001",
"name": "Stylish Ergonomic Office Chair",
"description": "Experience ultimate comfort and productivity with our ergonomic office chair, designed for long hours of work. Features adjustable lumbar support, breathable mesh, and a smooth recline.",
"price": 299.99,
"currency": "USD",
"images": [
"/images/chair-main.jpg",
"/images/chair-side.jpg",
"/images/chair-angle.jpg",
"/images/chair-back.jpg"
],
"variants": [
{ "type": "Color", "value": "Black", "hex": "#000000" },
{ "type": "Color", "value": "Grey", "hex": "#808080" },
{ "type": "Color", "value": "Blue", "hex": "#0000FF" }
],
"averageRating": 4.7,
"reviewsCount": 128
}
Why this structure?
id: Essential for uniquely identifying the product, especially when fetching via URL.imagesarray: Allows for a gallery or carousel.variantsarray: Enables selection of different product options (e.g., color, size).- Other fields: Standard information you’d expect.
2. Component Composition for a Product Page
A feature-rich product page can be broken down into several distinct components. This modular approach makes our code easier to understand, maintain, and reuse.
Let’s visualize a possible component hierarchy for our product page:
What’s happening here?
ProductPage(Route Component): This will be our main component, responsible for fetching the product data based on the URL and orchestrating its child components. It acts as the “smart” component.ProductImageGallery: Displays the main product image and a set of thumbnails for selection. It will manage its own internal state for the currently selected image.ProductDetails: A container component that receives product data and renders various sub-details like name, price, description, and potentially other interactive elements.ProductVariantSelector: Allows users to choose product variations (e.g., color, size).QuantitySelector: Manages the quantity of the product the user wants to add to the cart.AddToCartButton: A simple button that triggers the “add to cart” action.
This breakdown ensures each component has a clear, single responsibility, making debugging and future enhancements much simpler!
3. Dynamic Routing with react-router-dom
To display a specific product, our URL needs to include a product identifier. We’ll use dynamic route parameters, like /products/prod-001, where prod-001 is the productId. react-router-dom’s useParams hook will be crucial here.
4. Asynchronous Data Fetching
In a real application, product data would come from an API. We’ll simulate this using setTimeout to mimic network latency. The useEffect hook will be used to trigger this data fetch when the component mounts or when the productId changes. We’ll also need useState to manage loading, error, and the actual product data.
5. State Management for Interactive Elements
ProductImageGallery: Needs local state to track which image is currently displayed.QuantitySelector: Needs local state to track the selected quantity.ProductVariantSelector: Needs local state to track the selected variant (e.g., “Blue” color).ProductPage: Will manage the overall product data, loading, and error states. It will also handle theonAddToCartlogic, which might eventually interact with a global cart state (we’ll keep it simple for now).
Step-by-Step Implementation
Let’s start building our e-commerce product page! We’ll begin with a fresh React project.
Step 1: Project Setup and Mock Data
First, let’s create a new React project using Vite, the modern and fast build tool.
Create a new Vite project: Open your terminal or command prompt and run:
npm create vite@latest my-ecommerce-app -- --template reactmy-ecommerce-app: This is the name of your project folder.--template react: Specifies that we want a React project.
Navigate into your project and install dependencies:
cd my-ecommerce-app npm installInstall
react-router-dom: We’ll need this for routing.npm install react-router-dom@6.22.3(Note: As of 2026-01-31,
react-router-domv6.22.3 is the latest stable release at the time of writing, providing modern routing features.)Create Mock Product Data: Inside your
srcfolder, create a new file nameddata/products.js(you might need to create thedatafolder first). This will simulate our product database.// src/data/products.js const products = [ { id: "prod-001", name: "Stylish Ergonomic Office Chair", description: "Experience ultimate comfort and productivity with our ergonomic office chair, designed for long hours of work. Features adjustable lumbar support, breathable mesh, and a smooth recline for personalized support. Perfect for home offices and professional workspaces.", price: 299.99, currency: "USD", images: [ "/images/chair-main.jpg", "/images/chair-side.jpg", "/images/chair-angle.jpg", "/images/chair-back.jpg" ], variants: [ { type: "Color", value: "Black", hex: "#000000" }, { type: "Color", value: "Grey", hex: "#808080" }, { type: "Color", value: "Blue", hex: "#0000FF" } ], averageRating: 4.7, reviewsCount: 128 }, { id: "prod-002", name: "Premium Wireless Keyboard", description: "Boost your typing efficiency with our premium wireless keyboard. Featuring silent mechanical keys, customizable RGB backlighting, and a long-lasting battery. Connects seamlessly via Bluetooth or 2.4GHz USB receiver.", price: 129.99, currency: "USD", images: [ "/images/keyboard-main.jpg", "/images/keyboard-angle.jpg", "/images/keyboard-rgb.jpg" ], variants: [ { type: "Layout", value: "US QWERTY" }, { type: "Layout", value: "UK QWERTY" } ], averageRating: 4.5, reviewsCount: 85 }, { id: "prod-003", name: "Ultra-Thin 4K Monitor", description: "Immerse yourself in stunning visuals with our 27-inch Ultra-Thin 4K Monitor. Boasting vibrant colors, wide viewing angles, and a sleek, bezel-less design. Perfect for gaming, graphic design, and everyday productivity.", price: 499.00, currency: "USD", images: [ "/images/monitor-front.jpg", "/images/monitor-side.jpg", "/images/monitor-ports.jpg" ], variants: [], // No specific variants for this product averageRating: 4.8, reviewsCount: 210 } ]; export const fetchProductById = (id) => { return new Promise((resolve) => { setTimeout(() => { const product = products.find((p) => p.id === id); if (product) { resolve(product); } else { resolve(null); // Or reject with an error } }, 500); // Simulate network delay }); }; export default products;Explanation:
- We have an array
productscontaining our mock data. fetchProductByIdis an asynchronous function that simulates an API call. It returns aPromisethat resolves after 500ms with the product data ornullif not found. This is a common pattern for fetching data in React.
- We have an array
Add Product Images: Create an
imagesfolder inside yourpublicdirectory (public/images). This is where Vite (and other bundlers) expect static assets that should be served directly. Download or create some placeholder images and name them exactly as they appear inproducts.js(e.g.,chair-main.jpg,keyboard-main.jpg, etc.). If you don’t have images, you can use placeholder services likehttps://via.placeholder.com/600x400in yourproducts.jsfile for now.For example, if using placeholders:
// ... inside products.js, for prod-001 images: [ "https://via.placeholder.com/600x400?text=Chair+Main", "https://via.placeholder.com/600x400?text=Chair+Side", "https://via.placeholder.com/600x400?text=Chair+Angle", "https://via.placeholder.com/600x400?text=Chair+Back" ], // ...
Step 2: Setting up Routing
Now, let’s configure react-router-dom to handle our product page.
Modify
src/main.jsx: We need to wrap ourAppcomponent withBrowserRouter.// src/main.jsx import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <BrowserRouter> {/* Wrap App with BrowserRouter */} <App /> </BrowserRouter> </React.StrictMode>, );Explanation:
BrowserRouteris the recommended router for web applications. It uses the HTML5 history API to keep your UI in sync with the URL. By wrappingAppwith it, all components withinAppcan use routing features.
Create
ProductPagecomponent: Create a new filesrc/components/ProductPage.jsx(createcomponentsfolder). This will be our main product page component.// src/components/ProductPage.jsx import React from 'react'; import { useParams } from 'react-router-dom'; const ProductPage = () => { const { productId } = useParams(); // Get the productId from the URL return ( <div className="product-page-container"> <h2>Product Details for ID: {productId}</h2> <p>This is where our product data will be loaded.</p> {/* We'll add more content here soon! */} </div> ); }; export default ProductPage;Explanation:
- We import
useParamsfromreact-router-dom. useParams()returns an object of key/value pairs of URL parameters. Our route will define:productId, so we destructureproductIdfrom the returned object.- For now, it just displays the
productId.
- We import
Update
src/App.jsx: We’ll define our routes here.// src/App.jsx import React from 'react'; import { Routes, Route, Link } from 'react-router-dom'; import ProductPage from './components/ProductPage'; // Import our new component import './App.css'; // Assuming you have some basic styling function Home() { return ( <div className="home-page"> <h1>Welcome to our E-commerce Store!</h1> <p>Explore some of our amazing products:</p> <ul> <li><Link to="/products/prod-001">Stylish Ergonomic Office Chair</Link></li> <li><Link to="/products/prod-002">Premium Wireless Keyboard</Link></li> <li><Link to="/products/prod-003">Ultra-Thin 4K Monitor</Link></li> {/* Try navigating to a non-existent product */} <li><Link to="/products/non-existent-id">Non-existent Product</Link></li> </ul> </div> ); } function App() { return ( <div className="app-container"> <nav className="main-nav"> <Link to="/">Home</Link> </nav> <Routes> <Route path="/" element={<Home />} /> <Route path="/products/:productId" element={<ProductPage />} /> {/* A catch-all route for 404 Not Found */} <Route path="*" element={<h2>404 - Page Not Found</h2>} /> </Routes> </div> ); } export default App;Explanation:
- We import
Routes,Route, andLinkfromreact-router-dom. Routesis a container for all ourRoutecomponents.<Route path="/" element={<Home />} />: This defines the route for our homepage.<Route path="/products/:productId" element={<ProductPage />} />: This is our dynamic route.:productIdis a placeholder thatuseParamswill capture.<Route path="*" element={<h2>404 - Page Not Found</h2>} />: A wildcard route to catch any undefined paths, providing a basic 404 page.Linkcomponents are used for navigation, preventing full page reloads.
- We import
Basic Styling (
src/App.cssandsrc/index.css): Let’s add some minimal styling to make things readable./* src/index.css */ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 0; background-color: #f4f7f6; color: #333; } /* src/App.css */ .app-container { max-width: 1200px; margin: 20px auto; padding: 20px; background-color: #fff; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); border-radius: 8px; } .main-nav { padding-bottom: 20px; border-bottom: 1px solid #eee; margin-bottom: 20px; } .main-nav a { text-decoration: none; color: #007bff; font-weight: bold; margin-right: 15px; } .home-page ul { list-style: none; padding: 0; } .home-page li { margin-bottom: 10px; } .home-page a { color: #28a745; text-decoration: none; } .home-page a:hover { text-decoration: underline; } .product-page-container { padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px; background-color: #fdfdfd; }Run your app:
npm run devOpen your browser to
http://localhost:5173(or whatever port Vite uses). You should see the home page with product links. Click on a link, and you’ll see theProductPagedisplaying the correctproductId.
Step 3: Fetching and Displaying Product Data
Now, let’s make our ProductPage actually load and display product information.
Update
src/components/ProductPage.jsxto fetch data:// src/components/ProductPage.jsx import React, { useState, useEffect } from 'react'; // Import useState and useEffect import { useParams } from 'react-router-dom'; import { fetchProductById } from '../data/products'; // Import our data fetching function const ProductPage = () => { const { productId } = useParams(); const [product, setProduct] = useState(null); // State to store the fetched product const [loading, setLoading] = useState(true); // State for loading indicator const [error, setError] = useState(null); // State for error messages useEffect(() => { const getProduct = async () => { setLoading(true); // Start loading setError(null); // Clear any previous errors try { const fetchedProduct = await fetchProductById(productId); if (fetchedProduct) { setProduct(fetchedProduct); } else { setError("Product not found."); setProduct(null); // Ensure product is null if not found } } catch (err) { console.error("Failed to fetch product:", err); setError("Failed to load product data. Please try again."); } finally { setLoading(false); // End loading, whether successful or not } }; getProduct(); // Call the async function // Cleanup function (optional for this simple case, but good practice) return () => { // Any cleanup if needed when the component unmounts or productId changes }; }, [productId]); // Re-run effect if productId changes if (loading) { return <div className="product-page-container">Loading product...</div>; } if (error) { return <div className="product-page-container error-message">Error: {error}</div>; } if (!product) { return <div className="product-page-container">No product data available.</div>; } // If we reach here, product data is loaded and ready to display return ( <div className="product-page-container"> <div className="product-layout"> {/* Product Image Gallery will go here */} <div className="product-main-info"> <h1>{product.name}</h1> <p className="product-price">${product.price.toFixed(2)} {product.currency}</p> <p className="product-description">{product.description}</p> <p className="product-rating"> Rating: {product.averageRating} ({product.reviewsCount} reviews) </p> {/* Product Variant Selector, Quantity Selector, Add to Cart will go here */} </div> </div> </div> ); }; export default ProductPage;Explanation:
useState(null)forproduct: Initializesproducttonullbecause we don’t have data yet.useState(true)forloading: We start in a loading state.useState(null)forerror: To capture any issues during fetching.useEffect(() => { ... }, [productId]): This hook runs its function:- When the component first mounts.
- Whenever
productIdchanges (e.g., if you navigate from/products/prod-001to/products/prod-002).
getProductasync function: An inner async function is defined to handle theawaitcall.- It sets
loadingtotrueand clearserrorbefore fetching. await fetchProductById(productId): Calls our simulated API.try...catch: Robust error handling for network issues or API failures.finally: EnsuressetLoading(false)always runs.
- It sets
- Conditional Rendering: We check
loading,error, andproductin that order.- If
loadingis true, show “Loading…”. - If
errorexists, display the error message. - If
productisnull(and not loading/error), show “No product data”. - Finally, if
productdata is available, render the product details.
- If
product.price.toFixed(2): Formats the price to two decimal places.
Add more CSS for product display (
src/App.css):/* src/App.css (add to existing styles) */ .product-layout { display: flex; gap: 40px; /* Space between image gallery and details */ flex-wrap: wrap; /* Allow wrapping on smaller screens */ } .product-image-gallery, .product-main-info { flex: 1; /* Make them take equal space, or adjust as needed */ min-width: 300px; /* Ensure they don't get too small */ } .product-main-info h1 { font-size: 2.2em; margin-bottom: 10px; color: #212529; } .product-price { font-size: 1.8em; font-weight: bold; color: #dc3545; /* A common color for prices */ margin-bottom: 20px; } .product-description { font-size: 1.1em; line-height: 1.6; margin-bottom: 20px; } .product-rating { font-size: 0.9em; color: #6c757d; margin-bottom: 20px; } .error-message { color: #dc3545; font-weight: bold; padding: 15px; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 5px; }
Now, when you navigate to /products/prod-001, you’ll see “Loading product…” for half a second, then the details of the ergonomic chair. If you go to /products/non-existent-id, you’ll get the “Product not found.” error. This is great progress!
Step 4: Building the Product Image Gallery
Let’s create a dedicated component for the image gallery. It will manage its own state for the currently displayed image.
Create
src/components/ProductImageGallery.jsx:// src/components/ProductImageGallery.jsx import React, { useState, useEffect } from 'react'; const ProductImageGallery = ({ images }) => { // Set the first image as the initially selected one const [mainImage, setMainImage] = useState(images[0]); // Effect to update mainImage if the 'images' prop changes (e.g., new product loaded) useEffect(() => { if (images && images.length > 0) { setMainImage(images[0]); } }, [images]); // Dependency array: re-run if 'images' prop changes if (!images || images.length === 0) { return <div className="image-gallery-placeholder">No images available.</div>; } return ( <div className="product-image-gallery"> <div className="main-image-display"> <img src={mainImage} alt="Main product image" /> </div> <div className="thumbnail-gallery"> {images.map((image, index) => ( <img key={index} // Using index as key for now, but a unique ID is better if available src={image} alt={`Product thumbnail ${index + 1}`} className={`thumbnail ${image === mainImage ? 'active' : ''}`} onClick={() => setMainImage(image)} // Update main image on click /> ))} </div> </div> ); }; export default ProductImageGallery;Explanation:
mainImagestate: Stores the URL of the image currently displayed in the large view. It defaults to the first image from theimagesprop.useEffectforimagesprop: This is important! If theProductPagefetches a new product, itsimagesprop will change. ThisuseEffectensures ourmainImagestate resets to the first image of the new product, preventing a stale image from the previous product.mapoverimages: Renders each image as a thumbnail.onClick={() => setMainImage(image)}: When a thumbnail is clicked, its URL becomes themainImage.- **
className={${image === mainImage ? ‘active’ : ‘’}:** Adds anactive` class to the currently selected thumbnail for styling.
Integrate
ProductImageGalleryintosrc/components/ProductPage.jsx:// src/components/ProductPage.jsx (update render section) // ... imports and state ... import ProductImageGallery from './ProductImageGallery'; // Import the gallery component const ProductPage = () => { // ... existing code ... if (!product) { return <div className="product-page-container">No product data available.</div>; } return ( <div className="product-page-container"> <div className="product-layout"> <ProductImageGallery images={product.images} /> {/* Render the gallery */} <div className="product-main-info"> <h1>{product.name}</h1> <p className="product-price">${product.price.toFixed(2)} {product.currency}</p> <p className="product-description">{product.description}</p> <p className="product-rating"> Rating: {product.averageRating} ({product.reviewsCount} reviews) </p> {/* Other components will go here */} </div> </div> </div> ); }; export default ProductPage;Explanation:
- We simply render
ProductImageGalleryand passproduct.imagesas a prop.
- We simply render
Add styling for the gallery (
src/App.css):/* src/App.css (add to existing styles) */ .product-image-gallery { display: flex; flex-direction: column; gap: 15px; align-items: center; } .main-image-display { width: 100%; max-width: 500px; /* Limit main image size */ border: 1px solid #eee; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } .main-image-display img { width: 100%; height: auto; display: block; /* Remove extra space below image */ } .thumbnail-gallery { display: flex; gap: 10px; justify-content: center; /* Center thumbnails */ flex-wrap: wrap; /* Allow thumbnails to wrap */ } .thumbnail-gallery img { width: 80px; /* Fixed size for thumbnails */ height: 80px; object-fit: cover; /* Crop images to fit */ border: 2px solid #eee; border-radius: 4px; cursor: pointer; transition: border-color 0.2s ease, transform 0.2s ease; } .thumbnail-gallery img:hover { border-color: #007bff; transform: scale(1.05); } .thumbnail-gallery img.active { border-color: #007bff; /* Highlight active thumbnail */ box-shadow: 0 0 0 2px #007bff; } .image-gallery-placeholder { width: 100%; max-width: 500px; height: 300px; background-color: #e9ecef; display: flex; justify-content: center; align-items: center; color: #6c757d; border-radius: 8px; }Now, when you refresh your product page, you should see the main image and clickable thumbnails below it!
Step 5: Creating the Quantity Selector
Next, a component for selecting the product quantity.
Create
src/components/QuantitySelector.jsx:// src/components/QuantitySelector.jsx import React, { useState } from 'react'; const QuantitySelector = ({ initialQuantity = 1, min = 1, max = 10, onQuantityChange }) => { const [quantity, setQuantity] = useState(initialQuantity); const handleDecrement = () => { setQuantity(prevQuantity => { const newQuantity = Math.max(min, prevQuantity - 1); onQuantityChange(newQuantity); // Notify parent of change return newQuantity; }); }; const handleIncrement = () => { setQuantity(prevQuantity => { const newQuantity = Math.min(max, prevQuantity + 1); onQuantityChange(newQuantity); // Notify parent of change return newQuantity; }); }; const handleInputChange = (event) => { const value = parseInt(event.target.value, 10); if (!isNaN(value) && value >= min && value <= max) { setQuantity(value); onQuantityChange(value); // Notify parent of change } else if (event.target.value === '') { // Allow empty string temporarily for user input, but don't call onQuantityChange setQuantity(''); } }; return ( <div className="quantity-selector"> <label htmlFor="product-quantity">Quantity:</label> <div className="quantity-controls"> <button onClick={handleDecrement} disabled={quantity <= min}>-</button> <input id="product-quantity" type="number" value={quantity} onChange={handleInputChange} min={min} max={max} aria-label="Product quantity" /> <button onClick={handleIncrement} disabled={quantity >= max}>+</button> </div> </div> ); }; export default QuantitySelector;Explanation:
quantitystate: Manages the selected quantity within this component.initialQuantity,min,max,onQuantityChangeprops:initialQuantity: Starting value.min,max: Constraints for quantity.onQuantityChange: A callback function passed from the parent (ProductPage) to notify it when the quantity changes. This is a common pattern for “lifting state up”.
handleDecrement,handleIncrement: Functions to adjust the quantity, respectingminandmax. They also callonQuantityChange.handleInputChange: Handles direct input into the number field, with validation.disabledattribute: Buttons are disabled whenminormaxis reached.- Accessibility:
labelfor input,aria-labelfor better screen reader experience.
Integrate
QuantitySelectorintosrc/components/ProductPage.jsx: We’ll also need a state inProductPageto keep track of the selected quantity for the “Add to Cart” action.// src/components/ProductPage.jsx (update imports and state) // ... existing imports ... import QuantitySelector from './QuantitySelector'; // Import QuantitySelector const ProductPage = () => { const { productId } = useParams(); const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedQuantity, setSelectedQuantity] = useState(1); // New state for quantity useEffect(() => { const getProduct = async () => { setLoading(true); setError(null); try { const fetchedProduct = await fetchProductById(productId); if (fetchedProduct) { setProduct(fetchedProduct); setSelectedQuantity(1); // Reset quantity when a new product is loaded } else { setError("Product not found."); setProduct(null); } } catch (err) { console.error("Failed to fetch product:", err); setError("Failed to load product data. Please try again."); } finally { setLoading(false); } }; getProduct(); }, [productId]); // Function to handle quantity changes from QuantitySelector const handleQuantityChange = (newQuantity) => { setSelectedQuantity(newQuantity); }; // Function to simulate adding to cart const handleAddToCart = () => { console.log(`Adding ${selectedQuantity} of ${product.name} (ID: ${product.id}) to cart.`); alert(`Added ${selectedQuantity} of ${product.name} to cart! (Simulated)`); // In a real app, you'd dispatch an action to a global state manager (e.g., Redux, Zustand, Context) }; if (loading) { /* ... */ } if (error) { /* ... */ } if (!product) { /* ... */ } return ( <div className="product-page-container"> <div className="product-layout"> <ProductImageGallery images={product.images} /> <div className="product-main-info"> <h1>{product.name}</h1> <p className="product-price">${product.price.toFixed(2)} {product.currency}</p> <p className="product-description">{product.description}</p> <p className="product-rating"> Rating: {product.averageRating} ({product.reviewsCount} reviews) </p> {/* Quantity Selector */} <QuantitySelector initialQuantity={1} min={1} max={10} onQuantityChange={handleQuantityChange} /> {/* Add to Cart Button */} <button className="add-to-cart-button" onClick={handleAddToCart} disabled={selectedQuantity === 0 || loading} // Disable if quantity is 0 or still loading > Add to Cart </button> </div> </div> </div> ); }; export default ProductPage;Explanation:
selectedQuantitystate:ProductPagenow holds the quantity that will be added to the cart.handleQuantityChange: This function is passed toQuantitySelectorvia theonQuantityChangeprop. WhenQuantitySelector’s internal quantity changes, it calls this function, updatingselectedQuantityin the parentProductPage. This is “lifting state up”.handleAddToCart: A placeholder function that logs the product and quantity. In a real application, this would dispatch an action to add the item to a global cart state.setSelectedQuantity(1)inuseEffect: It’s good practice to reset the quantity to1when a new product is loaded.
Add styling for the quantity selector and button (
src/App.css):/* src/App.css (add to existing styles) */ .quantity-selector { margin-top: 20px; margin-bottom: 25px; display: flex; align-items: center; gap: 10px; font-size: 1.1em; } .quantity-controls { display: flex; border: 1px solid #ced4da; border-radius: 5px; overflow: hidden; } .quantity-controls button { background-color: #e9ecef; border: none; padding: 8px 15px; cursor: pointer; font-size: 1.1em; transition: background-color 0.2s; } .quantity-controls button:hover:not(:disabled) { background-color: #dee2e6; } .quantity-controls button:disabled { cursor: not-allowed; opacity: 0.6; } .quantity-controls input { width: 50px; text-align: center; border: none; border-left: 1px solid #ced4da; border-right: 1px solid #ced4da; padding: 8px 0; font-size: 1.1em; -moz-appearance: textfield; /* Hide arrows in Firefox */ } .quantity-controls input::-webkit-outer-spin-button, .quantity-controls input::-webkit-inner-spin-button { -webkit-appearance: none; /* Hide arrows in Chrome, Safari */ margin: 0; } .add-to-cart-button { background-color: #28a745; color: white; border: none; padding: 12px 25px; font-size: 1.2em; border-radius: 5px; cursor: pointer; transition: background-color 0.2s ease, transform 0.1s ease; width: 100%; /* Make button full width */ max-width: 250px; /* Limit max width */ box-shadow: 0 2px 5px rgba(40, 167, 69, 0.2); } .add-to-cart-button:hover:not(:disabled) { background-color: #218838; transform: translateY(-1px); } .add-to-cart-button:disabled { background-color: #6c757d; cursor: not-allowed; opacity: 0.7; }Now your product page has an interactive quantity selector and an “Add to Cart” button!
Step 6: Implementing a Product Variant Selector (Mini-Challenge Prep)
Let’s prepare for the mini-challenge by thinking about how to handle product variants.
Create
src/components/ProductVariantSelector.jsx:// src/components/ProductVariantSelector.jsx import React from 'react'; const ProductVariantSelector = ({ variants, selectedVariant, onVariantChange }) => { if (!variants || variants.length === 0) { return null; // Don't render if no variants } // Group variants by type (e.g., Color, Size) const groupedVariants = variants.reduce((acc, variant) => { if (!acc[variant.type]) { acc[variant.type] = []; } acc[variant.type].push(variant); return acc; }, {}); return ( <div className="product-variant-selector"> {Object.entries(groupedVariants).map(([type, options]) => ( <div key={type} className="variant-group"> <label className="variant-label">{type}:</label> <div className="variant-options"> {options.map((option, index) => ( <button key={option.value} // Use option.value as key if unique, otherwise index className={`variant-option-button ${selectedVariant && selectedVariant.value === option.value ? 'active' : ''}`} onClick={() => onVariantChange(option)} style={option.hex ? { backgroundColor: option.hex, borderColor: option.hex === '#FFFFFF' ? '#ccc' : option.hex } : {}} aria-label={`${type} ${option.value}`} > {option.hex ? '' : option.value} {/* Show text for non-color variants */} </button> ))} </div> </div> ))} </div> ); }; export default ProductVariantSelector;Explanation:
variants,selectedVariant,onVariantChangeprops: Similar toQuantitySelector, this component takesvariantsdata, the currentlyselectedVariant, and a callbackonVariantChange.groupedVariants: This logic groups variants by theirtype(e.g., “Color”, “Size”). This allows us to render separate selectors for each variant type.- Conditional rendering for color: If a variant has a
hexproperty, we render a color swatch; otherwise, we render its text value. activeclass: Highlights the currently selected variant.
Add styling for the variant selector (
src/App.css):/* src/App.css (add to existing styles) */ .product-variant-selector { margin-top: 20px; margin-bottom: 20px; } .variant-group { margin-bottom: 15px; } .variant-label { font-weight: bold; margin-bottom: 8px; display: block; font-size: 1.1em; } .variant-options { display: flex; gap: 10px; flex-wrap: wrap; } .variant-option-button { padding: 8px 15px; border: 2px solid #ced4da; border-radius: 5px; background-color: #fff; cursor: pointer; font-size: 0.95em; transition: all 0.2s ease; min-width: 40px; /* Ensure color swatches have some size */ min-height: 40px; display: flex; justify-content: center; align-items: center; } .variant-option-button:hover:not(.active) { border-color: #007bff; background-color: #f8f9fa; } .variant-option-button.active { border-color: #007bff; background-color: #e7f5ff; color: #007bff; font-weight: bold; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); }
Mini-Challenge: Integrate the Variant Selector
Your challenge is to integrate the ProductVariantSelector into ProductPage.
- Add state to
ProductPageto track theselectedVariant. Initialize it tonullor the first available variant. - Pass the
variantsprop fromproduct.variantstoProductVariantSelector. - Pass a
handleVariantChangefunction toProductVariantSelectorvia theonVariantChangeprop, similar to howonQuantityChangeworks. - Update the
handleAddToCartfunction to also log theselectedVariant. - Consider what happens if a product has no variants. The component should gracefully not render.
Take your time, think about how state flows, and try to implement it before looking at the hint!
Hint: Click to reveal a hint if you're stuck!
- In
ProductPage.jsx, addconst [selectedVariant, setSelectedVariant] = useState(null); - In the
useEffectwhere you fetch the product, aftersetProduct(fetchedProduct);, you should also set the initial selected variant. For example:if (fetchedProduct.variants && fetchedProduct.variants.length > 0) { setSelectedVariant(fetchedProduct.variants[0]); } else { setSelectedVariant(null); } - Create a
handleVariantChangefunction:const handleVariantChange = (variant) => { setSelectedVariant(variant); }; - Render the
ProductVariantSelectorbelow the description:<ProductVariantSelector variants={product.variants} selectedVariant={selectedVariant} onVariantChange={handleVariantChange} /> - Modify
handleAddToCartto includeselectedVariant?.valuein the log.
Solution: Click to reveal the full solution for the Mini-Challenge
// src/components/ProductPage.jsx (Updated with Variant Selector)
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { fetchProductById } from '../data/products';
import ProductImageGallery from './ProductImageGallery';
import QuantitySelector from './QuantitySelector';
import ProductVariantSelector from './ProductVariantSelector'; // Import the variant selector
const ProductPage = () => {
const { productId } = useParams();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedQuantity, setSelectedQuantity] = useState(1);
const [selectedVariant, setSelectedVariant] = useState(null); // New state for selected variant
useEffect(() => {
const getProduct = async () => {
setLoading(true);
setError(null);
try {
const fetchedProduct = await fetchProductById(productId);
if (fetchedProduct) {
setProduct(fetchedProduct);
setSelectedQuantity(1); // Reset quantity for new product
// Set initial variant if available
if (fetchedProduct.variants && fetchedProduct.variants.length > 0) {
setSelectedVariant(fetchedProduct.variants[0]);
} else {
setSelectedVariant(null);
}
} else {
setError("Product not found.");
setProduct(null);
setSelectedVariant(null); // Clear variant if product not found
}
} catch (err) {
console.error("Failed to fetch product:", err);
setError("Failed to load product data. Please try again.");
} finally {
setLoading(false);
}
};
getProduct();
}, [productId]);
const handleQuantityChange = (newQuantity) => {
setSelectedQuantity(newQuantity);
};
const handleVariantChange = (variant) => {
setSelectedVariant(variant);
};
const handleAddToCart = () => {
const variantInfo = selectedVariant ? ` (Variant: ${selectedVariant.value})` : '';
console.log(`Adding ${selectedQuantity} of ${product.name}${variantInfo} (ID: ${product.id}) to cart.`);
alert(`Added ${selectedQuantity} of ${product.name}${variantInfo} to cart! (Simulated)`);
};
if (loading) {
return <div className="product-page-container">Loading product...</div>;
}
if (error) {
return <div className="product-page-container error-message">Error: {error}</div>;
}
if (!product) {
return <div className="product-page-container">No product data available.</div>;
}
return (
<div className="product-page-container">
<div className="product-layout">
<ProductImageGallery images={product.images} />
<div className="product-main-info">
<h1>{product.name}</h1>
<p className="product-price">${product.price.toFixed(2)} {product.currency}</p>
<p className="product-description">{product.description}</p>
<p className="product-rating">
Rating: {product.averageRating} ({product.reviewsCount} reviews)
</p>
<ProductVariantSelector
variants={product.variants}
selectedVariant={selectedVariant}
onVariantChange={handleVariantChange}
/>
<QuantitySelector
initialQuantity={1}
min={1}
max={10}
onQuantityChange={handleQuantityChange}
/>
<button
className="add-to-cart-button"
onClick={handleAddToCart}
disabled={selectedQuantity === 0 || loading || (product.variants.length > 0 && !selectedVariant)}
>
Add to Cart
</button>
</div>
</div>
</div>
);
};
export default ProductPage;
What to Observe/Learn from the Mini-Challenge: You should observe how:
- State is lifted up from
ProductVariantSelectortoProductPage. - The
useEffecthook correctly initializesselectedVariantwhen a new product loads. - Conditional logic in
handleAddToCartaccounts for whether a variant was selected. - The “Add to Cart” button is disabled if variants exist but none are selected. This demonstrates robust UI interaction based on state.
Common Pitfalls & Troubleshooting
Infinite
useEffectLoops:- Pitfall: Accidentally putting
setProduct,setLoading, orsetError(or any state update) directly insideuseEffectwithout a proper dependency array, or having a dependency that changes on every render. - Troubleshooting: Check your
useEffectdependency array ([]). Ensure that state update functions are not in the dependency array unless they are wrapped inuseCallback(which is generally overkill for simple setters). In our case,[productId]is correct because we want the effect to re-run when the product ID changes.
- Pitfall: Accidentally putting
Handling
nullorundefinedData:- Pitfall: Trying to access properties of
product(e.g.,product.name) beforeproducthas been loaded or if the product doesn’t exist. This leads to “Cannot read property ’name’ of null/undefined” errors. - Troubleshooting: Always use conditional rendering checks (
if (loading),if (error),if (!product)) at the top of your component. This ensures you only try to render product details whenproductis a valid object. You can also use optional chaining (product?.name) for safer property access in JSX, but top-level conditional rendering is usually cleaner for full components.
- Pitfall: Trying to access properties of
Prop Drilling for Global State (like a Cart):
- Pitfall: As your application grows, you might find yourself passing
onAddToCartand cart data through many layers of components that don’t directly need them. This is called “prop drilling.” - Troubleshooting: While not a “pitfall” for this chapter’s scope (we’re simulating), be aware that for a real cart, you’d typically use a global state management solution (like React Context API, Redux Toolkit, Zustand, or Jotai) to make the cart data and actions accessible directly to components that need them, without passing props down unnecessarily. We’ll explore these patterns in future chapters!
- Pitfall: As your application grows, you might find yourself passing
Summary
Congratulations! You’ve successfully built a significant portion of a feature-rich e-commerce product page. Here’s what you’ve accomplished:
- Project Setup: Initialized a modern React project with Vite and
react-router-dom. - Mock Data & Async Fetching: Created simulated product data and used
useEffectto fetch it asynchronously, mimicking a real API call. - Dynamic Routing: Implemented
react-router-domto create dynamic routes for individual product pages usinguseParams. - Component Composition: Mastered breaking down a complex UI into smaller, reusable components like
ProductImageGallery,QuantitySelector, andProductVariantSelector. - Local Component State: Effectively used
useStateto manage interactive elements within components (e.g.,mainImagein the gallery,quantityin the selector). - Lifting State Up: Practiced passing callback functions via props (
onQuantityChange,onVariantChange) to update parent component state, enabling communication between child and parent. - Conditional Rendering: Handled loading, error, and no-data states gracefully, providing a better user experience.
- Practical Application: Applied many core React concepts to build a tangible, real-world UI.
This chapter was a big step in understanding how individual React features combine to create robust applications. You’re now equipped to build more complex, interactive user interfaces!
What’s Next?
In the upcoming chapters, we’ll continue to refine and enhance our e-commerce application. We’ll likely delve into:
- Global State Management: Implementing a proper cart using React Context or a library like Zustand.
- Forms and Validation: Creating more sophisticated forms for checkout or user reviews.
- Performance Optimizations: Ensuring our product page loads and responds quickly.
Keep up the great work!
References
- React Official Documentation: https://react.dev/
- React Router Documentation: https://reactrouter.com/en/main
- Vite Official Documentation: https://vitejs.dev/
- MDN Web Docs - Using fetch: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.