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-dom Basics: Setting up routes and using useParams and Link.
  • 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.
  • images array: Allows for a gallery or carousel.
  • variants array: 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:

graph TD A[ProductPage] --> B[ProductImageGallery] A --> C[ProductDetails] C --> D[ProductName] C --> E[ProductPrice] C --> F[ProductDescription] C --> G[ProductRating] C --> H[ProductVariantSelector] C --> I[QuantitySelector] C --> J[AddToCartButton]

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 the onAddToCart logic, 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.

  1. Create a new Vite project: Open your terminal or command prompt and run:

    npm create vite@latest my-ecommerce-app -- --template react
    
    • my-ecommerce-app: This is the name of your project folder.
    • --template react: Specifies that we want a React project.
  2. Navigate into your project and install dependencies:

    cd my-ecommerce-app
    npm install
    
  3. Install 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-dom v6.22.3 is the latest stable release at the time of writing, providing modern routing features.)

  4. Create Mock Product Data: Inside your src folder, create a new file named data/products.js (you might need to create the data folder 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 products containing our mock data.
    • fetchProductById is an asynchronous function that simulates an API call. It returns a Promise that resolves after 500ms with the product data or null if not found. This is a common pattern for fetching data in React.
  5. Add Product Images: Create an images folder inside your public directory (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 in products.js (e.g., chair-main.jpg, keyboard-main.jpg, etc.). If you don’t have images, you can use placeholder services like https://via.placeholder.com/600x400 in your products.js file 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.

  1. Modify src/main.jsx: We need to wrap our App component with BrowserRouter.

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

    • BrowserRouter is the recommended router for web applications. It uses the HTML5 history API to keep your UI in sync with the URL. By wrapping App with it, all components within App can use routing features.
  2. Create ProductPage component: Create a new file src/components/ProductPage.jsx (create components folder). 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 useParams from react-router-dom.
    • useParams() returns an object of key/value pairs of URL parameters. Our route will define :productId, so we destructure productId from the returned object.
    • For now, it just displays the productId.
  3. 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, and Link from react-router-dom.
    • Routes is a container for all our Route components.
    • <Route path="/" element={<Home />} />: This defines the route for our homepage.
    • <Route path="/products/:productId" element={<ProductPage />} />: This is our dynamic route. :productId is a placeholder that useParams will capture.
    • <Route path="*" element={<h2>404 - Page Not Found</h2>} />: A wildcard route to catch any undefined paths, providing a basic 404 page.
    • Link components are used for navigation, preventing full page reloads.
  4. Basic Styling (src/App.css and src/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;
    }
    
  5. Run your app:

    npm run dev
    

    Open 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 the ProductPage displaying the correct productId.

Step 3: Fetching and Displaying Product Data

Now, let’s make our ProductPage actually load and display product information.

  1. Update src/components/ProductPage.jsx to 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) for product: Initializes product to null because we don’t have data yet.
    • useState(true) for loading: We start in a loading state.
    • useState(null) for error: To capture any issues during fetching.
    • useEffect(() => { ... }, [productId]): This hook runs its function:
      • When the component first mounts.
      • Whenever productId changes (e.g., if you navigate from /products/prod-001 to /products/prod-002).
    • getProduct async function: An inner async function is defined to handle the await call.
      • It sets loading to true and clears error before fetching.
      • await fetchProductById(productId): Calls our simulated API.
      • try...catch: Robust error handling for network issues or API failures.
      • finally: Ensures setLoading(false) always runs.
    • Conditional Rendering: We check loading, error, and product in that order.
      • If loading is true, show “Loading…”.
      • If error exists, display the error message.
      • If product is null (and not loading/error), show “No product data”.
      • Finally, if product data is available, render the product details.
    • product.price.toFixed(2): Formats the price to two decimal places.
  2. 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!

Let’s create a dedicated component for the image gallery. It will manage its own state for the currently displayed image.

  1. 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:

    • mainImage state: Stores the URL of the image currently displayed in the large view. It defaults to the first image from the images prop.
    • useEffect for images prop: This is important! If the ProductPage fetches a new product, its images prop will change. This useEffect ensures our mainImage state resets to the first image of the new product, preventing a stale image from the previous product.
    • map over images: Renders each image as a thumbnail.
    • onClick={() => setMainImage(image)}: When a thumbnail is clicked, its URL becomes the mainImage.
    • **className={${image === mainImage ? ‘active’ : ‘’}:** Adds an active` class to the currently selected thumbnail for styling.
  2. Integrate ProductImageGallery into src/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 ProductImageGallery and pass product.images as a prop.
  3. 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.

  1. 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:

    • quantity state: Manages the selected quantity within this component.
    • initialQuantity, min, max, onQuantityChange props:
      • 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, respecting min and max. They also call onQuantityChange.
    • handleInputChange: Handles direct input into the number field, with validation.
    • disabled attribute: Buttons are disabled when min or max is reached.
    • Accessibility: label for input, aria-label for better screen reader experience.
  2. Integrate QuantitySelector into src/components/ProductPage.jsx: We’ll also need a state in ProductPage to 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:

    • selectedQuantity state: ProductPage now holds the quantity that will be added to the cart.
    • handleQuantityChange: This function is passed to QuantitySelector via the onQuantityChange prop. When QuantitySelector’s internal quantity changes, it calls this function, updating selectedQuantity in the parent ProductPage. 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) in useEffect: It’s good practice to reset the quantity to 1 when a new product is loaded.
  3. 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.

  1. 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, onVariantChange props: Similar to QuantitySelector, this component takes variants data, the currently selectedVariant, and a callback onVariantChange.
    • groupedVariants: This logic groups variants by their type (e.g., “Color”, “Size”). This allows us to render separate selectors for each variant type.
    • Conditional rendering for color: If a variant has a hex property, we render a color swatch; otherwise, we render its text value.
    • active class: Highlights the currently selected variant.
  2. 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.

  1. Add state to ProductPage to track the selectedVariant. Initialize it to null or the first available variant.
  2. Pass the variants prop from product.variants to ProductVariantSelector.
  3. Pass a handleVariantChange function to ProductVariantSelector via the onVariantChange prop, similar to how onQuantityChange works.
  4. Update the handleAddToCart function to also log the selectedVariant.
  5. 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!
  1. In ProductPage.jsx, add const [selectedVariant, setSelectedVariant] = useState(null);
  2. In the useEffect where you fetch the product, after setProduct(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); }
  3. Create a handleVariantChange function: const handleVariantChange = (variant) => { setSelectedVariant(variant); };
  4. Render the ProductVariantSelector below the description:
    <ProductVariantSelector
      variants={product.variants}
      selectedVariant={selectedVariant}
      onVariantChange={handleVariantChange}
    />
    
  5. Modify handleAddToCart to include selectedVariant?.value in 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 ProductVariantSelector to ProductPage.
  • The useEffect hook correctly initializes selectedVariant when a new product loads.
  • Conditional logic in handleAddToCart accounts 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

  1. Infinite useEffect Loops:

    • Pitfall: Accidentally putting setProduct, setLoading, or setError (or any state update) directly inside useEffect without a proper dependency array, or having a dependency that changes on every render.
    • Troubleshooting: Check your useEffect dependency array ([]). Ensure that state update functions are not in the dependency array unless they are wrapped in useCallback (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.
  2. Handling null or undefined Data:

    • Pitfall: Trying to access properties of product (e.g., product.name) before product has 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 when product is 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.
  3. Prop Drilling for Global State (like a Cart):

    • Pitfall: As your application grows, you might find yourself passing onAddToCart and 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!

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 useEffect to fetch it asynchronously, mimicking a real API call.
  • Dynamic Routing: Implemented react-router-dom to create dynamic routes for individual product pages using useParams.
  • Component Composition: Mastered breaking down a complex UI into smaller, reusable components like ProductImageGallery, QuantitySelector, and ProductVariantSelector.
  • Local Component State: Effectively used useState to manage interactive elements within components (e.g., mainImage in the gallery, quantity in 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

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.