Skip to main content

๐Ÿ›๏ธ Module 5 Project: E-commerce Product Catalog

Welcome to your capstone project for Module 5! You're about to build a complete e-commerce product catalog with a fully functional shopping cart. This project brings together everything you've learned: useReducer for complex state management, Context for sharing data, useRef for DOM interactions, useMemo/useCallback for performance, and compound components for flexible UIs. Think of this as building a mini Amazon - you'll have product browsing, filtering, cart management, and checkout. By the end, you'll have a professional-grade application that showcases your mastery of advanced React patterns. Let's build something awesome! ๐Ÿš€

๐ŸŽฏ Project Overview

What You'll Build: A complete e-commerce product catalog with shopping cart functionality

Core Features:

  • ๐Ÿ“ฆ Product catalog with grid/list view toggle
  • ๐Ÿ” Search and filter products by category, price range
  • ๐Ÿ›’ Shopping cart with add/remove/update quantity
  • ๐Ÿ’ฐ Real-time cart total calculation
  • ๐ŸŽจ Responsive design for mobile and desktop
  • โšก Performance optimizations with memoization
  • โ™ฟ Accessible UI components
  • ๐Ÿ” Type-safe with TypeScript

Estimated Time: 3-4 hours

Difficulty: Intermediate to Advanced

๐ŸŽ“ Learning Objectives

By completing this project, you will:

  • โœ… Use useReducer to manage complex shopping cart state
  • โœ… Implement Context API for global state sharing
  • โœ… Apply useMemo and useCallback for performance optimization
  • โœ… Build compound components for flexible UI
  • โœ… Use useRef for DOM manipulation and focus management
  • โœ… Implement real-world e-commerce features
  • โœ… Handle complex TypeScript types and interfaces
  • โœ… Create a professional, production-ready application

๐Ÿ“‘ Project Guide

๐Ÿš€ Project Setup

Let's set up the foundation for our e-commerce application. We'll create the project structure and install any necessary dependencies.

Project Structure

ecommerce-catalog/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”œโ”€โ”€ Cart/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ Cart.tsx
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ CartItem.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ CartSummary.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ Product/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ ProductCard.tsx
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ ProductGrid.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ ProductList.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ Filters/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ SearchBar.tsx
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ CategoryFilter.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ PriceFilter.tsx
โ”‚   โ”‚   โ””โ”€โ”€ Layout/
โ”‚   โ”‚       โ”œโ”€โ”€ Header.tsx
โ”‚   โ”‚       โ””โ”€โ”€ Layout.tsx
โ”‚   โ”œโ”€โ”€ context/
โ”‚   โ”‚   โ”œโ”€โ”€ CartContext.tsx
โ”‚   โ”‚   โ””โ”€โ”€ CartReducer.ts
โ”‚   โ”œโ”€โ”€ types/
โ”‚   โ”‚   โ””โ”€โ”€ index.ts
โ”‚   โ”œโ”€โ”€ data/
โ”‚   โ”‚   โ””โ”€โ”€ products.ts
โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ”‚   โ””โ”€โ”€ useCart.ts
โ”‚   โ”œโ”€โ”€ App.tsx
โ”‚   โ””โ”€โ”€ main.tsx
โ”œโ”€โ”€ package.json
โ””โ”€โ”€ tsconfig.json

Initial Setup Commands

# Create new Vite project with React + TypeScript
npm create vite@latest ecommerce-catalog -- --template react-ts

# Navigate to project
cd ecommerce-catalog

# Install dependencies
npm install

# Start development server
npm run dev

File Structure Overview

Directory/File Purpose
components/ All React components organized by feature
context/ Context providers and reducers for global state
types/ TypeScript interfaces and types
data/ Mock product data
hooks/ Custom hooks for reusable logic

๐Ÿ’ก Development Tips

  • Start with types and interfaces - this guides everything else
  • Build the reducer and context first - it's the foundation
  • Create components incrementally - test as you go
  • Use console.log to debug state changes
  • Install React DevTools browser extension for debugging

๐Ÿ“‹ Data Models and Types

Let's define all the TypeScript interfaces and types we'll need. Good types make development easier and catch bugs early!

Product Type

// src/types/index.ts

export interface Product {
    id: string;
    name: string;
    description: string;
    price: number;
    category: string;
    image: string;
    stock: number;
    rating: number;
    reviews: number;
}

export type ProductCategory = 
    | 'Electronics'
    | 'Clothing'
    | 'Books'
    | 'Home & Garden'
    | 'Sports'
    | 'All';

export interface PriceRange {
    min: number;
    max: number;
}

Cart Types

// Cart item (product + quantity)
export interface CartItem {
    product: Product;
    quantity: number;
}

// Cart state
export interface CartState {
    items: CartItem[];
    total: number;
}

// Cart actions
export type CartAction =
    | { type: 'ADD_TO_CART'; payload: Product }
    | { type: 'REMOVE_FROM_CART'; payload: string } // product id
    | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
    | { type: 'CLEAR_CART' }
    | { type: 'LOAD_CART'; payload: CartItem[] };

// Cart context value
export interface CartContextValue {
    state: CartState;
    addToCart: (product: Product) => void;
    removeFromCart: (productId: string) => void;
    updateQuantity: (productId: string, quantity: number) => void;
    clearCart: () => void;
    itemCount: number;
}

Filter Types

export interface FilterState {
    searchQuery: string;
    category: ProductCategory;
    priceRange: PriceRange;
    sortBy: 'name' | 'price-asc' | 'price-desc' | 'rating';
}

export type ViewMode = 'grid' | 'list';

Mock Product Data

// src/data/products.ts
import { Product } from '../types';

export const products: Product[] = [
    {
        id: '1',
        name: 'Wireless Headphones',
        description: 'Premium noise-cancelling wireless headphones with 30-hour battery life',
        price: 299.99,
        category: 'Electronics',
        image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400',
        stock: 15,
        rating: 4.5,
        reviews: 234
    },
    {
        id: '2',
        name: 'Smart Watch',
        description: 'Fitness tracker with heart rate monitor and GPS',
        price: 199.99,
        category: 'Electronics',
        image: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400',
        stock: 25,
        rating: 4.3,
        reviews: 156
    },
    {
        id: '3',
        name: 'Laptop Backpack',
        description: 'Water-resistant backpack with laptop compartment',
        price: 49.99,
        category: 'Clothing',
        image: 'https://images.unsplash.com/photo-1553062407-98eeb64c6a62?w=400',
        stock: 42,
        rating: 4.7,
        reviews: 89
    },
    {
        id: '4',
        name: 'JavaScript: The Definitive Guide',
        description: 'Comprehensive guide to JavaScript programming',
        price: 39.99,
        category: 'Books',
        image: 'https://images.unsplash.com/photo-1544947950-fa07a98d237f?w=400',
        stock: 30,
        rating: 4.8,
        reviews: 445
    },
    {
        id: '5',
        name: 'Coffee Maker',
        description: 'Programmable coffee maker with thermal carafe',
        price: 79.99,
        category: 'Home & Garden',
        image: 'https://images.unsplash.com/photo-1517668808822-9ebb02f2a0e6?w=400',
        stock: 18,
        rating: 4.4,
        reviews: 201
    },
    {
        id: '6',
        name: 'Yoga Mat',
        description: 'Non-slip yoga mat with carrying strap',
        price: 29.99,
        category: 'Sports',
        image: 'https://images.unsplash.com/photo-1601925260368-ae2f83cf8b7f?w=400',
        stock: 55,
        rating: 4.6,
        reviews: 312
    },
    {
        id: '7',
        name: 'Bluetooth Speaker',
        description: 'Portable waterproof speaker with 20-hour battery',
        price: 89.99,
        category: 'Electronics',
        image: 'https://images.unsplash.com/photo-1608043152269-423dbba4e7e1?w=400',
        stock: 33,
        rating: 4.5,
        reviews: 178
    },
    {
        id: '8',
        name: 'Running Shoes',
        description: 'Lightweight running shoes with cushioned sole',
        price: 119.99,
        category: 'Sports',
        image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=400',
        stock: 28,
        rating: 4.7,
        reviews: 267
    },
    {
        id: '9',
        name: 'LED Desk Lamp',
        description: 'Adjustable LED lamp with USB charging port',
        price: 34.99,
        category: 'Home & Garden',
        image: 'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400',
        stock: 47,
        rating: 4.3,
        reviews: 134
    },
    {
        id: '10',
        name: 'Cooking for Beginners',
        description: 'Essential cookbook with 100+ easy recipes',
        price: 24.99,
        category: 'Books',
        image: 'https://images.unsplash.com/photo-1512820790803-83ca734da794?w=400',
        stock: 61,
        rating: 4.6,
        reviews: 289
    },
    {
        id: '11',
        name: 'Wireless Mouse',
        description: 'Ergonomic wireless mouse with precision tracking',
        price: 39.99,
        category: 'Electronics',
        image: 'https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=400',
        stock: 75,
        rating: 4.4,
        reviews: 423
    },
    {
        id: '12',
        name: 'Denim Jacket',
        description: 'Classic denim jacket with modern fit',
        price: 69.99,
        category: 'Clothing',
        image: 'https://images.unsplash.com/photo-1576995853123-5a10305d93c0?w=400',
        stock: 22,
        rating: 4.5,
        reviews: 156
    }
];

// Helper function to get unique categories
export function getCategories(): ProductCategory[] {
    const categories = [...new Set(products.map(p => p.category))] as ProductCategory[];
    return ['All', ...categories];
}

โœ… Type Safety Benefits

  • Autocomplete for product properties
  • Compile-time error checking
  • Self-documenting code
  • Refactoring safety
  • Better IDE support

๐Ÿ’ก Pro Tip: Images

We're using Unsplash URLs for product images in this demo. In a real application, you would:

  • Store images in a CDN or cloud storage
  • Optimize images for web (WebP format, multiple sizes)
  • Implement lazy loading
  • Provide fallback images for errors

๐ŸŽฏ Cart State with useReducer

Now let's build the heart of our application - the cart reducer that manages all shopping cart operations.

Why useReducer for Cart State?

๐Ÿ’ก Benefits of useReducer

  • Complex state logic (add, remove, update, calculate totals)
  • Multiple related state updates in one action
  • Predictable state transitions
  • Easy to test independently
  • Better for debugging (action history)

Cart Reducer Implementation

// src/context/CartReducer.ts
import { CartState, CartAction, CartItem } from '../types';

// Helper function to calculate cart total
function calculateTotal(items: CartItem[]): number {
    return items.reduce((total, item) => {
        return total + (item.product.price * item.quantity);
    }, 0);
}

// Initial state
export const initialCartState: CartState = {
    items: [],
    total: 0
};

// Reducer function
export function cartReducer(state: CartState, action: CartAction): CartState {
    switch (action.type) {
        case 'ADD_TO_CART': {
            const existingItemIndex = state.items.findIndex(
                item => item.product.id === action.payload.id
            );
            
            let newItems: CartItem[];
            
            if (existingItemIndex > -1) {
                // Item already in cart - increase quantity
                newItems = state.items.map((item, index) => 
                    index === existingItemIndex
                        ? { ...item, quantity: item.quantity + 1 }
                        : item
                );
            } else {
                // New item - add to cart
                newItems = [
                    ...state.items,
                    { product: action.payload, quantity: 1 }
                ];
            }
            
            return {
                items: newItems,
                total: calculateTotal(newItems)
            };
        }
        
        case 'REMOVE_FROM_CART': {
            const newItems = state.items.filter(
                item => item.product.id !== action.payload
            );
            
            return {
                items: newItems,
                total: calculateTotal(newItems)
            };
        }
        
        case 'UPDATE_QUANTITY': {
            const { id, quantity } = action.payload;
            
            // Remove item if quantity is 0 or less
            if (quantity <= 0) {
                const newItems = state.items.filter(
                    item => item.product.id !== id
                );
                return {
                    items: newItems,
                    total: calculateTotal(newItems)
                };
            }
            
            // Update quantity
            const newItems = state.items.map(item =>
                item.product.id === id
                    ? { ...item, quantity }
                    : item
            );
            
            return {
                items: newItems,
                total: calculateTotal(newItems)
            };
        }
        
        case 'CLEAR_CART': {
            return initialCartState;
        }
        
        case 'LOAD_CART': {
            const items = action.payload;
            return {
                items,
                total: calculateTotal(items)
            };
        }
        
        default:
            return state;
    }
}

Understanding the Reducer

Action What It Does State Changes
ADD_TO_CART Add product or increase quantity Updates items array, recalculates total
REMOVE_FROM_CART Remove product completely Filters out item, recalculates total
UPDATE_QUANTITY Change item quantity Updates quantity, recalculates total
CLEAR_CART Empty entire cart Resets to initial state
LOAD_CART Load cart from storage Replaces items, calculates total

Testing the Reducer

// Example tests (conceptual - you can run these in console)
import { cartReducer, initialCartState } from './CartReducer';
import { products } from '../data/products';

// Test ADD_TO_CART
let state = cartReducer(initialCartState, {
    type: 'ADD_TO_CART',
    payload: products[0]
});
console.log('After adding product:', state);
// { items: [{ product: {...}, quantity: 1 }], total: 299.99 }

// Test adding same product again
state = cartReducer(state, {
    type: 'ADD_TO_CART',
    payload: products[0]
});
console.log('After adding again:', state);
// { items: [{ product: {...}, quantity: 2 }], total: 599.98 }

// Test UPDATE_QUANTITY
state = cartReducer(state, {
    type: 'UPDATE_QUANTITY',
    payload: { id: products[0].id, quantity: 5 }
});
console.log('After updating quantity:', state);
// { items: [{ product: {...}, quantity: 5 }], total: 1499.95 }

// Test REMOVE_FROM_CART
state = cartReducer(state, {
    type: 'REMOVE_FROM_CART',
    payload: products[0].id
});
console.log('After removing:', state);
// { items: [], total: 0 }

โœ… Reducer Best Practices

  • Always return a new state object (immutability)
  • Keep helper functions outside the reducer (calculateTotal)
  • Handle all edge cases (quantity <= 0, item not found)
  • Use TypeScript discriminated unions for actions
  • Make state transitions predictable and testable

๐ŸŒ Step 5: Creating the Cart Context

Now that we have our reducer, let's wrap it in a Context to make cart functionality available throughout our application. This demonstrates the powerful combination of useReducer and useContext.

Understanding Context with Reducer

Context provides a way to share data globally without prop drilling. When combined with useReducer, it creates a pattern similar to Redux but with less boilerplate.

graph TD A[CartProvider] --> B[useReducer] A --> C[Context Value] C --> D[state] C --> E[dispatch] C --> F[Helper Functions] G[Child Component 1] --> |useCartContext| C H[Child Component 2] --> |useCartContext| C I[Child Component 3] --> |useCartContext| C style A fill:#667eea,color:#fff style C fill:#48bb78,color:#fff style G fill:#f6e05e style H fill:#f6e05e style I fill:#f6e05e

Creating the Context Structure

Create src/context/CartContext.tsx:

import React, { createContext, useContext, useReducer, ReactNode, useCallback } from 'react';
import { cartReducer, initialCartState, CartState, CartAction } from '../reducers/CartReducer';
import { Product } from '../types';

// Define what our context will provide
interface CartContextValue {
    // State
    state: CartState;
    
    // Actions (wrapped in helper functions)
    addToCart: (product: Product) => void;
    removeFromCart: (productId: number) => void;
    updateQuantity: (productId: number, quantity: number) => void;
    clearCart: () => void;
    
    // Computed values
    itemCount: number;
    isEmpty: boolean;
}

// Create the context with undefined default (will be provided by provider)
const CartContext = createContext<CartContextValue | undefined>(undefined);

// Provider Props
interface CartProviderProps {
    children: ReactNode;
}

// Provider Component
export function CartProvider({ children }: CartProviderProps) {
    // Use our reducer
    const [state, dispatch] = useReducer(cartReducer, initialCartState);
    
    // Helper functions that wrap dispatch
    // useCallback prevents recreating these on every render
    const addToCart = useCallback((product: Product) => {
        dispatch({ type: 'ADD_TO_CART', payload: product });
    }, []);
    
    const removeFromCart = useCallback((productId: number) => {
        dispatch({ type: 'REMOVE_FROM_CART', payload: productId });
    }, []);
    
    const updateQuantity = useCallback((productId: number, quantity: number) => {
        dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
    }, []);
    
    const clearCart = useCallback(() => {
        dispatch({ type: 'CLEAR_CART' });
    }, []);
    
    // Computed values
    const itemCount = state.items.reduce((sum, item) => sum + item.quantity, 0);
    const isEmpty = state.items.length === 0;
    
    // Context value
    const value: CartContextValue = {
        state,
        addToCart,
        removeFromCart,
        updateQuantity,
        clearCart,
        itemCount,
        isEmpty
    };
    
    return (
        <CartContext.Provider value={value}>
            {children}
        </CartContext.Provider>
    );
}

// Custom hook to use the cart context
export function useCartContext() {
    const context = useContext(CartContext);
    
    if (context === undefined) {
        throw new Error('useCartContext must be used within a CartProvider');
    }
    
    return context;
}

๐Ÿ“– Key Concepts

useCallback: Memoizes functions so they don't change on every render. Important when passing functions to child components or using them as dependencies.

Computed Values: Derived from state but not stored in state. Calculated on-demand for efficiency.

Custom Hook Pattern: Encapsulates context usage and provides better error messages.

Setting Up the Provider

Wrap your application with the CartProvider in src/App.tsx:

import React from 'react';
import { CartProvider } from './context/CartContext';
import ProductCatalog from './components/ProductCatalog';
import ShoppingCart from './components/ShoppingCart';
import './App.css';

function App() {
    return (
        <CartProvider>
            <div className="app">
                <header className="app-header">
                    <h1>๐Ÿ›’ Tech Store</h1>
                    <p>Premium Electronics & Gadgets</p>
                </header>
                
                <main className="app-main">
                    <ProductCatalog />
                    <ShoppingCart />
                </main>
            </div>
        </CartProvider>
    );
}

export default App;

โš ๏ธ Common Pitfall

Provider Placement: Make sure the CartProvider wraps all components that need access to the cart. Components outside the provider will throw an error when trying to use useCartContext().

Using the Context in Components

Here's how any component can now access cart functionality:

import React from 'react';
import { useCartContext } from '../context/CartContext';

function CartSummary() {
    // Get everything we need from context
    const { state, itemCount, isEmpty } = useCartContext();
    
    if (isEmpty) {
        return <p>Your cart is empty</p>;
    }
    
    return (
        <div className="cart-summary">
            <p>Items in cart: {itemCount}</p>
            <p>Total: ${state.total.toFixed(2)}</p>
        </div>
    );
}

export default CartSummary;

๐Ÿ‹๏ธ Exercise: Test the Context

Create a simple component that uses the cart context:

๐Ÿ’ก Hint

Use the useCartContext hook and try calling addToCart with a test product.

โœ… Solution
import React from 'react';
import { useCartContext } from '../context/CartContext';

function TestCart() {
    const { addToCart, state, itemCount } = useCartContext();
    
    const testProduct = {
        id: 999,
        name: 'Test Product',
        price: 99.99,
        category: 'electronics',
        image: 'test.jpg',
        description: 'A test product',
        inStock: true,
        rating: 5
    };
    
    return (
        <div>
            <button onClick={() => addToCart(testProduct)}>
                Add Test Product
            </button>
            <p>Items in cart: {itemCount}</p>
            <p>Total: ${state.total.toFixed(2)}</p>
        </div>
    );
}

export default TestCart;

โœ… Context Best Practices

  • Single Responsibility: Keep contexts focused (CartContext for cart only)
  • Memoization: Use useCallback for functions to prevent unnecessary re-renders
  • Error Handling: Custom hook throws helpful errors when used incorrectly
  • Type Safety: Strongly type the context value for better DX
  • Computed Values: Include commonly needed derived state in context

๐Ÿช Step 6: Building the Product Catalog

Now let's create the main product display component. This will show our products in a grid and allow users to add items to their cart.

Component Structure Overview

The Product Catalog will be composed of several smaller components working together:

graph TD A[ProductCatalog] --> B[ProductGrid] A --> C[FilterBar] B --> D[ProductCard] B --> E[ProductCard] B --> F[ProductCard] D --> G[AddToCartButton] E --> G F --> G style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#ed8936,color:#fff style D fill:#f6e05e style E fill:#f6e05e style F fill:#f6e05e

Creating Product Data

First, let's create our product data. Create src/data/products.ts:

import { Product } from '../types';

export const products: Product[] = [
    {
        id: 1,
        name: 'Wireless Headphones Pro',
        price: 299.99,
        category: 'electronics',
        image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=300',
        description: 'Premium wireless headphones with active noise cancellation and 30-hour battery life.',
        inStock: true,
        rating: 4.8
    },
    {
        id: 2,
        name: 'Smart Watch Ultra',
        price: 449.99,
        category: 'electronics',
        image: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=300',
        description: 'Advanced fitness tracking, heart rate monitoring, and GPS navigation.',
        inStock: true,
        rating: 4.6
    },
    {
        id: 3,
        name: 'Laptop Stand Aluminum',
        price: 79.99,
        category: 'accessories',
        image: 'https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=300',
        description: 'Ergonomic laptop stand with adjustable height and angle.',
        inStock: true,
        rating: 4.5
    },
    {
        id: 4,
        name: 'Mechanical Keyboard RGB',
        price: 159.99,
        category: 'electronics',
        image: 'https://images.unsplash.com/photo-1595225476474-87563907a212?w=300',
        description: 'Cherry MX switches with customizable RGB lighting and programmable keys.',
        inStock: false,
        rating: 4.9
    },
    {
        id: 5,
        name: 'Wireless Mouse Pro',
        price: 89.99,
        category: 'electronics',
        image: 'https://images.unsplash.com/photo-1527814050087-3793815479db?w=300',
        description: 'Precision tracking, ergonomic design, and 60-day battery life.',
        inStock: true,
        rating: 4.7
    },
    {
        id: 6,
        name: 'USB-C Hub 7-in-1',
        price: 49.99,
        category: 'accessories',
        image: 'https://images.unsplash.com/photo-1625948515291-69613efd103f?w=300',
        description: '7 ports including HDMI, USB 3.0, SD card reader, and power delivery.',
        inStock: true,
        rating: 4.4
    },
    {
        id: 7,
        name: 'Webcam 4K Ultra HD',
        price: 179.99,
        category: 'electronics',
        image: 'https://images.unsplash.com/photo-1587826080692-f439cd0b70da?w=300',
        description: '4K video, auto-focus, built-in microphone, and professional lighting.',
        inStock: true,
        rating: 4.6
    },
    {
        id: 8,
        name: 'Phone Stand Adjustable',
        price: 24.99,
        category: 'accessories',
        image: 'https://images.unsplash.com/photo-1601784551446-20c9e07cdbdb?w=300',
        description: 'Foldable phone stand compatible with all smartphones and tablets.',
        inStock: true,
        rating: 4.3
    }
];

// Helper function to get products by category
export function getProductsByCategory(category: string): Product[] {
    return products.filter(product => product.category === category);
}

// Helper function to search products
export function searchProducts(query: string): Product[] {
    const lowerQuery = query.toLowerCase();
    return products.filter(product => 
        product.name.toLowerCase().includes(lowerQuery) ||
        product.description.toLowerCase().includes(lowerQuery)
    );
}

๐Ÿ’ก Real-World Note

In a production application, this data would come from an API. The structure we've created makes it easy to replace with API calls later using the data fetching patterns from Module 4.

ProductCard Component

Create src/components/ProductCard.tsx:

import React from 'react';
import { Product } from '../types';
import { useCartContext } from '../context/CartContext';
import './ProductCard.css';

interface ProductCardProps {
    product: Product;
}

function ProductCard({ product }: ProductCardProps) {
    const { addToCart, state } = useCartContext();
    
    // Check if product is already in cart
    const cartItem = state.items.find(item => item.product.id === product.id);
    const quantityInCart = cartItem?.quantity || 0;
    
    const handleAddToCart = () => {
        addToCart(product);
    };
    
    return (
        <article className="product-card">
            {/* Product Image */}
            <div className="product-image-wrapper">
                <img 
                    src={product.image} 
                    alt={product.name}
                    className="product-image"
                />
                
                {/* Stock Badge */}
                {!product.inStock && (
                    <span className="out-of-stock-badge">Out of Stock</span>
                )}
                
                {/* Quantity Badge */}
                {quantityInCart > 0 && (
                    <span className="quantity-badge">
                        {quantityInCart} in cart
                    </span>
                )}
            </div>
            
            {/* Product Info */}
            <div className="product-info">
                <h3 className="product-name">{product.name}</h3>
                
                <div className="product-rating">
                    {'โญ'.repeat(Math.floor(product.rating))}
                    <span className="rating-number">{product.rating}</span>
                </div>
                
                <p className="product-description">{product.description}</p>
                
                <div className="product-footer">
                    <span className="product-price">
                        ${product.price.toFixed(2)}
                    </span>
                    
                    <button 
                        onClick={handleAddToCart}
                        disabled={!product.inStock}
                        className={`add-to-cart-btn ${!product.inStock ? 'disabled' : ''}`}
                    >
                        {product.inStock ? '๐Ÿ›’ Add to Cart' : 'โŒ Unavailable'}
                    </button>
                </div>
            </div>
        </article>
    );
}

export default ProductCard;

๐Ÿ“– Component Features

Contextual Information: Shows quantity in cart if product is already added

Stock Status: Visual badge and disabled button for out-of-stock items

User Feedback: Clear visual states for different conditions

ProductCard Styling

Create src/components/ProductCard.css:

.product-card {
    background: white;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    overflow: hidden;
    transition: transform 0.2s, box-shadow 0.2s;
    display: flex;
    flex-direction: column;
}

.product-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

.product-image-wrapper {
    position: relative;
    width: 100%;
    padding-top: 75%; /* 4:3 aspect ratio */
    overflow: hidden;
    background: #f7fafc;
}

.product-image {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.out-of-stock-badge {
    position: absolute;
    top: 12px;
    right: 12px;
    background: #e53e3e;
    color: white;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 0.875rem;
    font-weight: 600;
}

.quantity-badge {
    position: absolute;
    top: 12px;
    left: 12px;
    background: #48bb78;
    color: white;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 0.875rem;
    font-weight: 600;
}

.product-info {
    padding: 1.25rem;
    display: flex;
    flex-direction: column;
    flex: 1;
}

.product-name {
    font-size: 1.125rem;
    font-weight: 600;
    color: #2d3748;
    margin: 0 0 0.5rem 0;
}

.product-rating {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
    font-size: 0.875rem;
}

.rating-number {
    color: #718096;
    font-weight: 500;
}

.product-description {
    color: #718096;
    font-size: 0.875rem;
    line-height: 1.5;
    margin: 0 0 1rem 0;
    flex: 1;
}

.product-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 1rem;
    margin-top: auto;
}

.product-price {
    font-size: 1.5rem;
    font-weight: 700;
    color: #667eea;
}

.add-to-cart-btn {
    background: #667eea;
    color: white;
    border: none;
    padding: 0.625rem 1.25rem;
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s;
    font-size: 0.875rem;
}

.add-to-cart-btn:hover:not(.disabled) {
    background: #5568d3;
}

.add-to-cart-btn.disabled {
    background: #cbd5e0;
    cursor: not-allowed;
}

@media (max-width: 768px) {
    .product-footer {
        flex-direction: column;
        align-items: stretch;
    }
    
    .add-to-cart-btn {
        width: 100%;
    }
}

ProductCatalog Component

Now let's create the main catalog component in src/components/ProductCatalog.tsx:

import React, { useState, useMemo } from 'react';
import ProductCard from './ProductCard';
import { products } from '../data/products';
import './ProductCatalog.css';

type FilterCategory = 'all' | 'electronics' | 'accessories';
type SortOption = 'name' | 'price-low' | 'price-high' | 'rating';

function ProductCatalog() {
    const [searchQuery, setSearchQuery] = useState('');
    const [categoryFilter, setCategoryFilter] = useState<FilterCategory>('all');
    const [sortBy, setSortBy] = useState<SortOption>('name');
    
    // Filter and sort products using useMemo for performance
    const displayedProducts = useMemo(() => {
        let filtered = products;
        
        // Filter by category
        if (categoryFilter !== 'all') {
            filtered = filtered.filter(p => p.category === categoryFilter);
        }
        
        // Filter by search query
        if (searchQuery.trim()) {
            const query = searchQuery.toLowerCase();
            filtered = filtered.filter(p => 
                p.name.toLowerCase().includes(query) ||
                p.description.toLowerCase().includes(query)
            );
        }
        
        // Sort
        const sorted = [...filtered];
        switch (sortBy) {
            case 'name':
                sorted.sort((a, b) => a.name.localeCompare(b.name));
                break;
            case 'price-low':
                sorted.sort((a, b) => a.price - b.price);
                break;
            case 'price-high':
                sorted.sort((a, b) => b.price - a.price);
                break;
            case 'rating':
                sorted.sort((a, b) => b.rating - a.rating);
                break;
        }
        
        return sorted;
    }, [searchQuery, categoryFilter, sortBy]);
    
    return (
        <div className="product-catalog">
            {/* Filter Controls */}
            <div className="catalog-controls">
                {/* Search */}
                <div className="search-box">
                    <input
                        type="text"
                        placeholder="๐Ÿ” Search products..."
                        value={searchQuery}
                        onChange={(e) => setSearchQuery(e.target.value)}
                        className="search-input"
                    />
                </div>
                
                {/* Category Filter */}
                <div className="filter-group">
                    <label>Category:</label>
                    <select 
                        value={categoryFilter}
                        onChange={(e) => setCategoryFilter(e.target.value as FilterCategory)}
                        className="filter-select"
                    >
                        <option value="all">All Products</option>
                        <option value="electronics">Electronics</option>
                        <option value="accessories">Accessories</option>
                    </select>
                </div>
                
                {/* Sort */}
                <div className="filter-group">
                    <label>Sort by:</label>
                    <select 
                        value={sortBy}
                        onChange={(e) => setSortBy(e.target.value as SortOption)}
                        className="filter-select"
                    >
                        <option value="name">Name</option>
                        <option value="price-low">Price: Low to High</option>
                        <option value="price-high">Price: High to Low</option>
                        <option value="rating">Highest Rated</option>
                    </select>
                </div>
            </div>
            
            {/* Results Count */}
            <div className="results-info">
                Showing {displayedProducts.length} of {products.length} products
            </div>
            
            {/* Product Grid */}
            {displayedProducts.length > 0 ? (
                <div className="product-grid">
                    {displayedProducts.map(product => (
                        <ProductCard key={product.id} product={product} />
                    ))}
                </div>
            ) : (
                <div className="no-results">
                    <p>๐Ÿ˜” No products found</p>
                    <p>Try adjusting your filters or search term</p>
                </div>
            )}
        </div>
    );
}

export default ProductCatalog;

โœ… Performance Optimization

useMemo: The filtering and sorting logic is wrapped in useMemo, so it only recalculates when dependencies change (searchQuery, categoryFilter, sortBy). This prevents expensive operations on every render.

ProductCatalog Styling

Create src/components/ProductCatalog.css:

.product-catalog {
    flex: 1;
    padding: 1.5rem;
}

.catalog-controls {
    background: white;
    padding: 1.5rem;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
    margin-bottom: 1.5rem;
}

.search-box {
    flex: 1;
    min-width: 250px;
}

.search-input {
    width: 100%;
    padding: 0.75rem 1rem;
    border: 2px solid #e2e8f0;
    border-radius: 8px;
    font-size: 1rem;
    transition: border-color 0.2s;
}

.search-input:focus {
    outline: none;
    border-color: #667eea;
}

.filter-group {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.filter-group label {
    font-weight: 600;
    color: #4a5568;
    white-space: nowrap;
}

.filter-select {
    padding: 0.75rem 1rem;
    border: 2px solid #e2e8f0;
    border-radius: 8px;
    font-size: 1rem;
    background: white;
    cursor: pointer;
    transition: border-color 0.2s;
}

.filter-select:focus {
    outline: none;
    border-color: #667eea;
}

.results-info {
    margin-bottom: 1rem;
    color: #718096;
    font-size: 0.875rem;
}

.product-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1.5rem;
}

.no-results {
    text-align: center;
    padding: 4rem 2rem;
    color: #718096;
}

.no-results p:first-child {
    font-size: 1.5rem;
    margin-bottom: 0.5rem;
}

@media (max-width: 768px) {
    .catalog-controls {
        flex-direction: column;
    }
    
    .search-box {
        min-width: 100%;
    }
    
    .filter-group {
        width: 100%;
        justify-content: space-between;
    }
    
    .filter-select {
        flex: 1;
    }
    
    .product-grid {
        grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
        gap: 1rem;
    }
}

๐Ÿ‹๏ธ Exercise: Add a Price Range Filter

Enhance the ProductCatalog with a price range filter. Add state for min/max price and update the filtering logic.

๐Ÿ’ก Hint

Add two number inputs for min and max price. In the useMemo, add a filter that checks if the product price is within the range.

โœ… Solution
// Add to state
const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 });

// Add to useMemo filtering
if (priceRange.min > 0 || priceRange.max < 1000) {
    filtered = filtered.filter(p => 
        p.price >= priceRange.min && p.price <= priceRange.max
    );
}

// Add to controls
<div className="filter-group">
    <label>Price Range:</label>
    <input
        type="number"
        value={priceRange.min}
        onChange={(e) => setPriceRange(prev => ({ 
            ...prev, 
            min: Number(e.target.value) 
        }))}
        min="0"
        max={priceRange.max}
    />
    <span>-</span>
    <input
        type="number"
        value={priceRange.max}
        onChange={(e) => setPriceRange(prev => ({ 
            ...prev, 
            max: Number(e.target.value) 
        }))}
        min={priceRange.min}
        max="1000"
    />
</div>

๐Ÿ›’ Step 7: Building the Shopping Cart

Now let's create the shopping cart component where users can review their items, update quantities, and see the total.

Cart Component Structure

The cart will feature:

  • ๐Ÿ’ณ Cart summary with total and item count
  • ๐Ÿ“ List of cart items with quantity controls
  • ๐Ÿ—‘๏ธ Remove item functionality
  • โœจ Empty cart state
  • ๐Ÿ“ฑ Collapsible on mobile
graph TD A[ShoppingCart] --> B[CartSummary] A --> C[CartItemList] C --> D[CartItem] C --> E[CartItem] D --> F[QuantityControls] D --> G[RemoveButton] E --> F E --> G style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#ed8936,color:#fff

CartItem Component

First, let's create the individual cart item. Create src/components/CartItem.tsx:

import React from 'react';
import { CartItem as CartItemType } from '../types';
import { useCartContext } from '../context/CartContext';
import './CartItem.css';

interface CartItemProps {
    item: CartItemType;
}

function CartItem({ item }: CartItemProps) {
    const { updateQuantity, removeFromCart } = useCartContext();
    const { product, quantity } = item;
    
    const itemTotal = product.price * quantity;
    
    const handleQuantityChange = (newQuantity: number) => {
        if (newQuantity >= 1) {
            updateQuantity(product.id, newQuantity);
        }
    };
    
    const handleIncrement = () => {
        handleQuantityChange(quantity + 1);
    };
    
    const handleDecrement = () => {
        if (quantity > 1) {
            handleQuantityChange(quantity - 1);
        }
    };
    
    const handleRemove = () => {
        if (window.confirm(`Remove ${product.name} from cart?`)) {
            removeFromCart(product.id);
        }
    };
    
    return (
        <div className="cart-item">
            {/* Product Image */}
            <img 
                src={product.image} 
                alt={product.name}
                className="cart-item-image"
            />
            
            {/* Product Details */}
            <div className="cart-item-details">
                <h4 className="cart-item-name">{product.name}</h4>
                <p className="cart-item-price">${product.price.toFixed(2)} each</p>
                
                {/* Quantity Controls */}
                <div className="quantity-controls">
                    <button 
                        onClick={handleDecrement}
                        className="quantity-btn"
                        aria-label="Decrease quantity"
                    >
                        โˆ’
                    </button>
                    
                    <input
                        type="number"
                        value={quantity}
                        onChange={(e) => handleQuantityChange(Number(e.target.value))}
                        min="1"
                        className="quantity-input"
                        aria-label="Quantity"
                    />
                    
                    <button 
                        onClick={handleIncrement}
                        className="quantity-btn"
                        aria-label="Increase quantity"
                    >
                        +
                    </button>
                </div>
            </div>
            
            {/* Item Total and Remove */}
            <div className="cart-item-actions">
                <p className="cart-item-total">
                    ${itemTotal.toFixed(2)}
                </p>
                <button 
                    onClick={handleRemove}
                    className="remove-btn"
                    aria-label={`Remove ${product.name} from cart`}
                >
                    ๐Ÿ—‘๏ธ Remove
                </button>
            </div>
        </div>
    );
}

export default CartItem;

๐Ÿ“– UX Considerations

Confirmation Dialog: We ask for confirmation before removing items to prevent accidental deletions.

Direct Input: Users can type a quantity directly or use increment/decrement buttons.

Accessibility: All buttons have aria-labels for screen readers.

CartItem Styling

Create src/components/CartItem.css:

.cart-item {
    display: flex;
    gap: 1rem;
    padding: 1rem;
    background: white;
    border-radius: 8px;
    border: 1px solid #e2e8f0;
    transition: box-shadow 0.2s;
}

.cart-item:hover {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.cart-item-image {
    width: 80px;
    height: 80px;
    object-fit: cover;
    border-radius: 8px;
    flex-shrink: 0;
}

.cart-item-details {
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.cart-item-name {
    margin: 0;
    font-size: 1rem;
    font-weight: 600;
    color: #2d3748;
}

.cart-item-price {
    margin: 0;
    font-size: 0.875rem;
    color: #718096;
}

.quantity-controls {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.quantity-btn {
    width: 32px;
    height: 32px;
    border: 2px solid #667eea;
    background: white;
    color: #667eea;
    border-radius: 6px;
    font-size: 1.25rem;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.2s;
    display: flex;
    align-items: center;
    justify-content: center;
}

.quantity-btn:hover {
    background: #667eea;
    color: white;
}

.quantity-input {
    width: 60px;
    height: 32px;
    text-align: center;
    border: 2px solid #e2e8f0;
    border-radius: 6px;
    font-size: 1rem;
    font-weight: 600;
}

.quantity-input:focus {
    outline: none;
    border-color: #667eea;
}

/* Remove spinner from number input */
.quantity-input::-webkit-inner-spin-button,
.quantity-input::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

.cart-item-actions {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    justify-content: space-between;
    gap: 0.5rem;
}

.cart-item-total {
    margin: 0;
    font-size: 1.25rem;
    font-weight: 700;
    color: #667eea;
    white-space: nowrap;
}

.remove-btn {
    background: #fed7d7;
    color: #c53030;
    border: none;
    padding: 0.5rem 0.75rem;
    border-radius: 6px;
    font-size: 0.875rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s;
}

.remove-btn:hover {
    background: #fc8181;
    color: white;
}

@media (max-width: 640px) {
    .cart-item {
        flex-direction: column;
    }
    
    .cart-item-image {
        width: 100%;
        height: 150px;
    }
    
    .cart-item-actions {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        width: 100%;
    }
}

ShoppingCart Component

Now create the main cart component in src/components/ShoppingCart.tsx:

import React, { useState } from 'react';
import { useCartContext } from '../context/CartContext';
import CartItem from './CartItem';
import './ShoppingCart.css';

function ShoppingCart() {
    const { state, isEmpty, itemCount, clearCart } = useCartContext();
    const [isOpen, setIsOpen] = useState(true);
    
    const handleClearCart = () => {
        if (window.confirm('Are you sure you want to clear your cart?')) {
            clearCart();
        }
    };
    
    const handleCheckout = () => {
        alert(`Checkout functionality coming soon!\nTotal: $${state.total.toFixed(2)}`);
    };
    
    return (
        <aside className={`shopping-cart ${isOpen ? 'open' : 'closed'}`}>
            {/* Cart Header */}
            <div className="cart-header">
                <h2>
                    ๐Ÿ›’ Shopping Cart 
                    {!isEmpty && <span className="cart-badge">{itemCount}</span>}
                </h2>
                <button 
                    onClick={() => setIsOpen(!isOpen)}
                    className="toggle-cart-btn"
                    aria-label={isOpen ? 'Close cart' : 'Open cart'}
                >
                    {isOpen ? 'โ–ถ' : 'โ—€'}
                </button>
            </div>
            
            {/* Cart Content */}
            <div className="cart-content">
                {isEmpty ? (
                    <div className="empty-cart">
                        <p className="empty-cart-icon">๐Ÿ›’</p>
                        <p className="empty-cart-text">Your cart is empty</p>
                        <p className="empty-cart-subtext">
                            Add some products to get started!
                        </p>
                    </div>
                ) : (
                    <>
                        {/* Cart Items */}
                        <div className="cart-items">
                            {state.items.map(item => (
                                <CartItem key={item.product.id} item={item} />
                            ))}
                        </div>
                        
                        {/* Cart Summary */}
                        <div className="cart-summary">
                            <div className="summary-row">
                                <span>Subtotal:</span>
                                <span>${state.total.toFixed(2)}</span>
                            </div>
                            <div className="summary-row">
                                <span>Items:</span>
                                <span>{itemCount}</span>
                            </div>
                            <div className="summary-row total">
                                <span>Total:</span>
                                <span>${state.total.toFixed(2)}</span>
                            </div>
                        </div>
                        
                        {/* Cart Actions */}
                        <div className="cart-actions">
                            <button 
                                onClick={handleCheckout}
                                className="checkout-btn"
                            >
                                ๐Ÿ’ณ Checkout
                            </button>
                            <button 
                                onClick={handleClearCart}
                                className="clear-cart-btn"
                            >
                                ๐Ÿ—‘๏ธ Clear Cart
                            </button>
                        </div>
                    </>
                )}
            </div>
        </aside>
    );
}

export default ShoppingCart;

โœ… Cart Features

  • Collapsible: Save screen space on mobile by toggling the cart
  • Empty State: Clear visual feedback when cart is empty
  • Summary: Real-time calculation of total and item count
  • Safety: Confirmation dialogs for destructive actions

ShoppingCart Styling

Create src/components/ShoppingCart.css:

.shopping-cart {
    width: 400px;
    background: #f7fafc;
    border-left: 1px solid #e2e8f0;
    display: flex;
    flex-direction: column;
    transition: transform 0.3s ease;
}

.shopping-cart.closed {
    transform: translateX(100%);
}

.cart-header {
    background: white;
    padding: 1.5rem;
    border-bottom: 1px solid #e2e8f0;
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: sticky;
    top: 0;
    z-index: 10;
}

.cart-header h2 {
    margin: 0;
    font-size: 1.25rem;
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.cart-badge {
    background: #667eea;
    color: white;
    border-radius: 50%;
    width: 24px;
    height: 24px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 0.75rem;
    font-weight: 700;
}

.toggle-cart-btn {
    background: #e2e8f0;
    border: none;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    cursor: pointer;
    transition: background 0.2s;
    display: none;
}

.toggle-cart-btn:hover {
    background: #cbd5e0;
}

.cart-content {
    flex: 1;
    overflow-y: auto;
    padding: 1rem;
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.cart-items {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.empty-cart {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 3rem 1rem;
}

.empty-cart-icon {
    font-size: 4rem;
    margin: 0 0 1rem 0;
    opacity: 0.3;
}

.empty-cart-text {
    font-size: 1.125rem;
    font-weight: 600;
    color: #4a5568;
    margin: 0 0 0.5rem 0;
}

.empty-cart-subtext {
    color: #718096;
    margin: 0;
}

.cart-summary {
    background: white;
    padding: 1.25rem;
    border-radius: 8px;
    margin-top: auto;
}

.summary-row {
    display: flex;
    justify-content: space-between;
    margin-bottom: 0.75rem;
    font-size: 1rem;
}

.summary-row.total {
    border-top: 2px solid #e2e8f0;
    padding-top: 0.75rem;
    margin-top: 0.75rem;
    font-size: 1.25rem;
    font-weight: 700;
    color: #667eea;
}

.cart-actions {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

.checkout-btn {
    background: #667eea;
    color: white;
    border: none;
    padding: 1rem;
    border-radius: 8px;
    font-size: 1rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s;
}

.checkout-btn:hover {
    background: #5568d3;
}

.clear-cart-btn {
    background: white;
    color: #c53030;
    border: 2px solid #fed7d7;
    padding: 0.75rem;
    border-radius: 8px;
    font-size: 0.875rem;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.2s;
}

.clear-cart-btn:hover {
    background: #fed7d7;
}

@media (max-width: 1024px) {
    .shopping-cart {
        position: fixed;
        right: 0;
        top: 0;
        height: 100vh;
        z-index: 1000;
        box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15);
    }
    
    .toggle-cart-btn {
        display: block;
    }
}

@media (max-width: 640px) {
    .shopping-cart {
        width: 100%;
    }
}

๐Ÿ‹๏ธ Exercise: Add Cart Persistence

Enhance the cart by saving it to localStorage so it persists across page refreshes. Hint: Use a useEffect in the CartProvider.

๐Ÿ’ก Hint

Create a useEffect that runs whenever the cart state changes and saves it to localStorage. On mount, check if there's saved data and use it as initial state.

โœ… Solution
// In CartProvider component
const CART_STORAGE_KEY = 'ecommerce-cart';

// Load initial state from localStorage
function getInitialState(): CartState {
    try {
        const saved = localStorage.getItem(CART_STORAGE_KEY);
        return saved ? JSON.parse(saved) : initialCartState;
    } catch (error) {
        console.error('Failed to load cart from localStorage:', error);
        return initialCartState;
    }
}

// Use the function to initialize state
const [state, dispatch] = useReducer(cartReducer, getInitialState());

// Save to localStorage whenever state changes
useEffect(() => {
    try {
        localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(state));
    } catch (error) {
        console.error('Failed to save cart to localStorage:', error);
    }
}, [state]);

Connecting Everything in App

Update your src/App.tsx to use both components:

import React from 'react';
import { CartProvider } from './context/CartContext';
import ProductCatalog from './components/ProductCatalog';
import ShoppingCart from './components/ShoppingCart';
import './App.css';

function App() {
    return (
        <CartProvider>
            <div className="app">
                <header className="app-header">
                    <div className="header-content">
                        <h1>๐Ÿ›’ Tech Store</h1>
                        <p>Premium Electronics & Gadgets</p>
                    </div>
                </header>
                
                <div className="app-layout">
                    <ProductCatalog />
                    <ShoppingCart />
                </div>
            </div>
        </CartProvider>
    );
}

export default App;

App Layout Styles

Update src/App.css for the overall layout:

.app {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    background: #f7fafc;
}

.app-header {
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    padding: 2rem 1.5rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.header-content {
    max-width: 1400px;
    margin: 0 auto;
}

.app-header h1 {
    margin: 0 0 0.5rem 0;
    font-size: 2rem;
}

.app-header p {
    margin: 0;
    opacity: 0.9;
    font-size: 1.125rem;
}

.app-layout {
    flex: 1;
    display: flex;
    max-width: 1400px;
    margin: 0 auto;
    width: 100%;
}

@media (max-width: 1024px) {
    .app-layout {
        flex-direction: column;
    }
}

@media (max-width: 640px) {
    .app-header {
        padding: 1.5rem 1rem;
    }
    
    .app-header h1 {
        font-size: 1.5rem;
    }
    
    .app-header p {
        font-size: 1rem;
    }
}

โœ… What We've Built

You now have a fully functional e-commerce catalog with:

  • โœ… Product display with images, ratings, and descriptions
  • โœ… Search and filter functionality
  • โœ… Sorting options
  • โœ… Add to cart with quantity tracking
  • โœ… Shopping cart with item management
  • โœ… Real-time total calculation
  • โœ… Responsive design for all devices
  • โœ… Professional styling and UX

โš ๏ธ Testing Checklist

Before moving forward, test these scenarios:

  1. Add products to cart and verify quantities update
  2. Use search to filter products
  3. Switch between category filters
  4. Sort products by different criteria
  5. Update quantities in the cart
  6. Remove items from cart
  7. Clear entire cart
  8. Test on mobile viewport (use browser dev tools)
  9. Try adding out-of-stock items (should be disabled)

๐Ÿ” Step 8: Advanced Filters and Search

Let's enhance our product catalog with more sophisticated filtering and search capabilities. We'll add features like multi-select filters, price ranges, and search highlighting.

Enhanced Filter Architecture

We'll create a more robust filtering system that can handle multiple criteria simultaneously:

graph TD A[User Input] --> B[Search Query] A --> C[Category Filter] A --> D[Price Range] A --> E[Rating Filter] A --> F[Stock Filter] B --> G[Filter Pipeline] C --> G D --> G E --> G F --> G G --> H[Sorted Results] H --> I[Product Grid] style A fill:#667eea,color:#fff style G fill:#48bb78,color:#fff style I fill:#ed8936,color:#fff

Creating a Filter Hook

Let's create a custom hook to manage all our filters. Create src/hooks/useProductFilters.ts:

import { useState, useMemo } from 'react';
import { Product } from '../types';

export interface FilterOptions {
    searchQuery: string;
    category: 'all' | 'electronics' | 'accessories';
    priceRange: { min: number; max: number };
    minRating: number;
    showOutOfStock: boolean;
}

export type SortOption = 'name' | 'price-low' | 'price-high' | 'rating';

const DEFAULT_FILTERS: FilterOptions = {
    searchQuery: '',
    category: 'all',
    priceRange: { min: 0, max: 1000 },
    minRating: 0,
    showOutOfStock: true
};

export function useProductFilters(products: Product[]) {
    const [filters, setFilters] = useState<FilterOptions>(DEFAULT_FILTERS);
    const [sortBy, setSortBy] = useState<SortOption>('name');
    
    // Update individual filter
    const updateFilter = <K extends keyof FilterOptions>(
        key: K,
        value: FilterOptions[K]
    ) => {
        setFilters(prev => ({ ...prev, [key]: value }));
    };
    
    // Reset all filters
    const resetFilters = () => {
        setFilters(DEFAULT_FILTERS);
        setSortBy('name');
    };
    
    // Apply all filters and sorting
    const filteredProducts = useMemo(() => {
        let result = products;
        
        // Search filter
        if (filters.searchQuery.trim()) {
            const query = filters.searchQuery.toLowerCase();
            result = result.filter(p => 
                p.name.toLowerCase().includes(query) ||
                p.description.toLowerCase().includes(query) ||
                p.category.toLowerCase().includes(query)
            );
        }
        
        // Category filter
        if (filters.category !== 'all') {
            result = result.filter(p => p.category === filters.category);
        }
        
        // Price range filter
        result = result.filter(p => 
            p.price >= filters.priceRange.min && 
            p.price <= filters.priceRange.max
        );
        
        // Rating filter
        if (filters.minRating > 0) {
            result = result.filter(p => p.rating >= filters.minRating);
        }
        
        // Stock filter
        if (!filters.showOutOfStock) {
            result = result.filter(p => p.inStock);
        }
        
        // Sort
        const sorted = [...result];
        switch (sortBy) {
            case 'name':
                sorted.sort((a, b) => a.name.localeCompare(b.name));
                break;
            case 'price-low':
                sorted.sort((a, b) => a.price - b.price);
                break;
            case 'price-high':
                sorted.sort((a, b) => b.price - a.price);
                break;
            case 'rating':
                sorted.sort((a, b) => b.rating - a.rating);
                break;
        }
        
        return sorted;
    }, [products, filters, sortBy]);
    
    // Active filter count (for UI badge)
    const activeFilterCount = useMemo(() => {
        let count = 0;
        if (filters.searchQuery.trim()) count++;
        if (filters.category !== 'all') count++;
        if (filters.priceRange.min > 0 || filters.priceRange.max < 1000) count++;
        if (filters.minRating > 0) count++;
        if (!filters.showOutOfStock) count++;
        return count;
    }, [filters]);
    
    return {
        filters,
        updateFilter,
        sortBy,
        setSortBy,
        resetFilters,
        filteredProducts,
        activeFilterCount
    };
}

๐Ÿ“– Custom Hook Benefits

Encapsulation: All filter logic is in one place, making it reusable and testable

Type Safety: Generic updateFilter function ensures type-safe updates

Performance: useMemo prevents unnecessary recalculations

DX: Simple API makes it easy to use in components

Enhanced FilterBar Component

Create a dedicated component for filters in src/components/FilterBar.tsx:

import React from 'react';
import { FilterOptions, SortOption } from '../hooks/useProductFilters';
import './FilterBar.css';

interface FilterBarProps {
    filters: FilterOptions;
    onFilterChange: <K extends keyof FilterOptions>(
        key: K,
        value: FilterOptions[K]
    ) => void;
    sortBy: SortOption;
    onSortChange: (sort: SortOption) => void;
    onReset: () => void;
    activeFilterCount: number;
    totalProducts: number;
    filteredCount: number;
}

function FilterBar({
    filters,
    onFilterChange,
    sortBy,
    onSortChange,
    onReset,
    activeFilterCount,
    totalProducts,
    filteredCount
}: FilterBarProps) {
    return (
        <div className="filter-bar">
            {/* Search Box */}
            <div className="filter-section search-section">
                <label htmlFor="search">๐Ÿ” Search</label>
                <input
                    id="search"
                    type="text"
                    placeholder="Search products..."
                    value={filters.searchQuery}
                    onChange={(e) => onFilterChange('searchQuery', e.target.value)}
                    className="search-input"
                />
            </div>
            
            {/* Category Filter */}
            <div className="filter-section">
                <label htmlFor="category">๐Ÿ“ฆ Category</label>
                <select
                    id="category"
                    value={filters.category}
                    onChange={(e) => onFilterChange('category', e.target.value as any)}
                    className="filter-select"
                >
                    <option value="all">All Categories</option>
                    <option value="electronics">Electronics</option>
                    <option value="accessories">Accessories</option>
                </select>
            </div>
            
            {/* Price Range */}
            <div className="filter-section price-section">
                <label>๐Ÿ’ฐ Price Range</label>
                <div className="price-inputs">
                    <input
                        type="number"
                        placeholder="Min"
                        value={filters.priceRange.min}
                        onChange={(e) => onFilterChange('priceRange', {
                            ...filters.priceRange,
                            min: Number(e.target.value)
                        })}
                        min="0"
                        className="price-input"
                    />
                    <span className="price-separator">-</span>
                    <input
                        type="number"
                        placeholder="Max"
                        value={filters.priceRange.max}
                        onChange={(e) => onFilterChange('priceRange', {
                            ...filters.priceRange,
                            max: Number(e.target.value)
                        })}
                        min={filters.priceRange.min}
                        className="price-input"
                    />
                </div>
            </div>
            
            {/* Rating Filter */}
            <div className="filter-section">
                <label htmlFor="rating">โญ Min Rating</label>
                <select
                    id="rating"
                    value={filters.minRating}
                    onChange={(e) => onFilterChange('minRating', Number(e.target.value))}
                    className="filter-select"
                >
                    <option value="0">Any Rating</option>
                    <option value="4">4+ Stars</option>
                    <option value="4.5">4.5+ Stars</option>
                </select>
            </div>
            
            {/* Stock Filter */}
            <div className="filter-section checkbox-section">
                <label className="checkbox-label">
                    <input
                        type="checkbox"
                        checked={filters.showOutOfStock}
                        onChange={(e) => onFilterChange('showOutOfStock', e.target.checked)}
                    />
                    <span>Show out of stock</span>
                </label>
            </div>
            
            {/* Sort */}
            <div className="filter-section">
                <label htmlFor="sort">๐Ÿ”„ Sort By</label>
                <select
                    id="sort"
                    value={sortBy}
                    onChange={(e) => onSortChange(e.target.value as SortOption)}
                    className="filter-select"
                >
                    <option value="name">Name</option>
                    <option value="price-low">Price: Low to High</option>
                    <option value="price-high">Price: High to Low</option>
                    <option value="rating">Highest Rated</option>
                </select>
            </div>
            
            {/* Results Info and Reset */}
            <div className="filter-section results-section">
                <div className="results-info">
                    Showing {filteredCount} of {totalProducts} products
                    {activeFilterCount > 0 && (
                        <span className="active-filters-badge">
                            {activeFilterCount} filter{activeFilterCount !== 1 ? 's' : ''}
                        </span>
                    )}
                </div>
                {activeFilterCount > 0 && (
                    <button onClick={onReset} className="reset-btn">
                        ๐Ÿ”„ Reset Filters
                    </button>
                )}
            </div>
        </div>
    );
}

export default FilterBar;

FilterBar Styling

Create src/components/FilterBar.css:

.filter-bar {
    background: white;
    padding: 1.5rem;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 1.25rem;
    margin-bottom: 1.5rem;
}

.filter-section {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.filter-section label {
    font-weight: 600;
    color: #4a5568;
    font-size: 0.875rem;
}

.search-section {
    grid-column: 1 / -1;
}

.search-input,
.filter-select,
.price-input {
    padding: 0.75rem 1rem;
    border: 2px solid #e2e8f0;
    border-radius: 8px;
    font-size: 1rem;
    transition: border-color 0.2s;
    background: white;
}

.search-input:focus,
.filter-select:focus,
.price-input:focus {
    outline: none;
    border-color: #667eea;
}

.price-section .price-inputs {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.price-input {
    flex: 1;
}

.price-separator {
    color: #718096;
    font-weight: 600;
}

.checkbox-section {
    justify-content: flex-end;
}

.checkbox-label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    cursor: pointer;
    padding: 0.75rem 0;
}

.checkbox-label input[type="checkbox"] {
    width: 18px;
    height: 18px;
    cursor: pointer;
}

.results-section {
    grid-column: 1 / -1;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
    padding-top: 1rem;
    border-top: 1px solid #e2e8f0;
}

.results-info {
    color: #718096;
    font-size: 0.875rem;
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.active-filters-badge {
    background: #667eea;
    color: white;
    padding: 0.25rem 0.75rem;
    border-radius: 12px;
    font-size: 0.75rem;
    font-weight: 600;
}

.reset-btn {
    background: #f7fafc;
    color: #667eea;
    border: 2px solid #667eea;
    padding: 0.5rem 1rem;
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.2s;
    font-size: 0.875rem;
}

.reset-btn:hover {
    background: #667eea;
    color: white;
}

@media (max-width: 768px) {
    .filter-bar {
        grid-template-columns: 1fr;
    }
    
    .results-section {
        flex-direction: column;
        align-items: stretch;
        gap: 1rem;
    }
    
    .reset-btn {
        width: 100%;
    }
}

๐Ÿ‹๏ธ Exercise: Add Search Highlighting

Enhance the ProductCard to highlight matching search terms in the product name and description.

๐Ÿ’ก Hint

Create a helper function that wraps matching text in a <mark> element. Use it when rendering the product name and description.

โœ… Solution
// Helper function to highlight search matches
function highlightText(text: string, query: string): JSX.Element {
    if (!query.trim()) {
        return <>{text}</>;
    }
    
    const parts = text.split(new RegExp(`(${query})`, 'gi'));
    return (
        <>
            {parts.map((part, index) => 
                part.toLowerCase() === query.toLowerCase() ? (
                    <mark key={index} style={{ 
                        background: '#fef3c7', 
                        padding: '0 2px',
                        borderRadius: '2px'
                    }}>
                        {part}
                    </mark>
                ) : (
                    <span key={index}>{part}</span>
                )
            )}
        </>
    );
}

// In ProductCard component, pass searchQuery as prop and use:
<h3 className="product-name">
    {highlightText(product.name, searchQuery)}
</h3>

Updated ProductCatalog with FilterBar

Update src/components/ProductCatalog.tsx to use the new hook and FilterBar:

import React from 'react';
import ProductCard from './ProductCard';
import FilterBar from './FilterBar';
import { products } from '../data/products';
import { useProductFilters } from '../hooks/useProductFilters';
import './ProductCatalog.css';

function ProductCatalog() {
    const {
        filters,
        updateFilter,
        sortBy,
        setSortBy,
        resetFilters,
        filteredProducts,
        activeFilterCount
    } = useProductFilters(products);
    
    return (
        <div className="product-catalog">
            <FilterBar
                filters={filters}
                onFilterChange={updateFilter}
                sortBy={sortBy}
                onSortChange={setSortBy}
                onReset={resetFilters}
                activeFilterCount={activeFilterCount}
                totalProducts={products.length}
                filteredCount={filteredProducts.length}
            />
            
            {filteredProducts.length > 0 ? (
                <div className="product-grid">
                    {filteredProducts.map(product => (
                        <ProductCard 
                            key={product.id} 
                            product={product}
                            searchQuery={filters.searchQuery}
                        />
                    ))}
                </div>
            ) : (
                <div className="no-results">
                    <p className="no-results-icon">๐Ÿ˜”</p>
                    <p className="no-results-text">No products found</p>
                    <p className="no-results-subtext">
                        Try adjusting your filters or search term
                    </p>
                    <button onClick={resetFilters} className="reset-btn-large">
                        ๐Ÿ”„ Reset All Filters
                    </button>
                </div>
            )}
        </div>
    );
}

export default ProductCatalog;

โœ… Filter System Benefits

  • Separation of Concerns: Logic in hook, UI in component
  • Composable: Easy to add/remove filters
  • Type-Safe: Generic function ensures correct types
  • User Feedback: Shows active filter count and results
  • Reset Capability: Quick way to clear all filters

โšก Step 9: Performance Optimization

Now that our e-commerce app is feature-complete, let's optimize its performance using React's built-in tools and best practices.

Performance Optimization Strategy

graph TD A[Performance Goals] --> B[Prevent Unnecessary Renders] A --> C[Optimize Expensive Operations] A --> D[Lazy Load Components] A --> E[Debounce User Input] B --> F[React.memo] B --> G[useCallback] C --> H[useMemo] D --> I[React.lazy] E --> J[Custom debounce hook] style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#ed8936,color:#fff style D fill:#f6ad55,color:#fff style E fill:#fc8181,color:#fff

1. Memoizing Components with React.memo

Wrap components that receive the same props frequently to prevent unnecessary re-renders:

// Update ProductCard.tsx
import React, { memo } from 'react';
import { Product } from '../types';
import { useCartContext } from '../context/CartContext';
import './ProductCard.css';

interface ProductCardProps {
    product: Product;
    searchQuery?: string;
}

function ProductCardComponent({ product, searchQuery = '' }: ProductCardProps) {
    const { addToCart, state } = useCartContext();
    
    // ... rest of component code
    
    return (
        <article className="product-card">
            {/* ... */}
        </article>
    );
}

// Memoize the component - only re-renders if props change
export default memo(ProductCardComponent);

๐Ÿ“– React.memo Explained

Purpose: Prevents re-renders when props haven't changed

When to Use: Components that render often with the same props

Shallow Comparison: By default, compares props using shallow equality

2. Creating a Debounced Search Hook

Debouncing prevents excessive filtering while the user is typing. Create src/hooks/useDebounce.ts:

import { useState, useEffect } from 'react';

/**
 * Debounces a value by delaying updates until after a specified delay
 * Useful for search inputs to reduce filtering operations
 */
export function useDebounce<T>(value: T, delay: number = 300): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);
    
    useEffect(() => {
        // Set up a timer to update the debounced value after delay
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);
        
        // Clean up the timer if value changes before delay expires
        return () => {
            clearTimeout(timer);
        };
    }, [value, delay]);
    
    return debouncedValue;
}

// Usage example:
// const debouncedSearch = useDebounce(searchQuery, 300);
// Now use debouncedSearch in your filtering logic instead of searchQuery

Implementing Debounced Search

Update the filter hook to use debouncing:

// In useProductFilters.ts
import { useDebounce } from './useDebounce';

export function useProductFilters(products: Product[]) {
    const [filters, setFilters] = useState<FilterOptions>(DEFAULT_FILTERS);
    const [sortBy, setSortBy] = useState<SortOption>('name');
    
    // Debounce the search query to reduce filtering operations
    const debouncedSearchQuery = useDebounce(filters.searchQuery, 300);
    
    // Apply all filters and sorting
    const filteredProducts = useMemo(() => {
        let result = products;
        
        // Use debounced search query instead of immediate value
        if (debouncedSearchQuery.trim()) {
            const query = debouncedSearchQuery.toLowerCase();
            result = result.filter(p => 
                p.name.toLowerCase().includes(query) ||
                p.description.toLowerCase().includes(query) ||
                p.category.toLowerCase().includes(query)
            );
        }
        
        // ... rest of filtering logic
        
    }, [products, debouncedSearchQuery, filters.category, 
        filters.priceRange, filters.minRating, 
        filters.showOutOfStock, sortBy]);
    
    // ... rest of hook
}

๐Ÿ’ก Debouncing Benefits

Performance: Reduces filtering operations from ~10/second to 1 every 300ms

User Experience: Smoother interaction, less CPU usage

Network Savings: Essential when search triggers API calls

3. Optimizing Cart Context

Ensure cart context functions are properly memoized:

// In CartContext.tsx - ensure all callbacks use useCallback
export function CartProvider({ children }: CartProviderProps) {
    const [state, dispatch] = useReducer(cartReducer, initialCartState);
    
    // Memoize all action functions
    const addToCart = useCallback((product: Product) => {
        dispatch({ type: 'ADD_TO_CART', payload: product });
    }, []); // Empty deps - function never changes
    
    const removeFromCart = useCallback((productId: number) => {
        dispatch({ type: 'REMOVE_FROM_CART', payload: productId });
    }, []);
    
    const updateQuantity = useCallback((productId: number, quantity: number) => {
        dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
    }, []);
    
    const clearCart = useCallback(() => {
        dispatch({ type: 'CLEAR_CART' });
    }, []);
    
    // Memoize computed values
    const itemCount = useMemo(
        () => state.items.reduce((sum, item) => sum + item.quantity, 0),
        [state.items]
    );
    
    const isEmpty = useMemo(
        () => state.items.length === 0,
        [state.items]
    );
    
    // Memoize the entire context value to prevent unnecessary re-renders
    const value = useMemo(() => ({
        state,
        addToCart,
        removeFromCart,
        updateQuantity,
        clearCart,
        itemCount,
        isEmpty
    }), [state, addToCart, removeFromCart, updateQuantity, clearCart, itemCount, isEmpty]);
    
    return (
        <CartContext.Provider value={value}>
            {children}
        </CartContext.Provider>
    );
}

4. Virtualizing Long Lists (Optional Enhancement)

For catalogs with hundreds of products, consider virtualization:

// Install: npm install react-window
// This is an advanced optimization for very large lists

import { FixedSizeGrid } from 'react-window';

function VirtualizedProductGrid({ products }: { products: Product[] }) {
    const columnCount = 3;
    const rowCount = Math.ceil(products.length / columnCount);
    
    const Cell = ({ columnIndex, rowIndex, style }: any) => {
        const index = rowIndex * columnCount + columnIndex;
        const product = products[index];
        
        if (!product) return null;
        
        return (
            <div style={style}>
                <ProductCard product={product} />
            </div>
        );
    };
    
    return (
        <FixedSizeGrid
            columnCount={columnCount}
            columnWidth={300}
            height={800}
            rowCount={rowCount}
            rowHeight={400}
            width={1000}
        >
            {Cell}
        </FixedSizeGrid>
    );
}

โš ๏ธ When to Virtualize

Only implement virtualization if:

  • You have 100+ items in your list
  • Users are experiencing performance issues
  • Rendering all items causes noticeable lag

For most e-commerce catalogs with pagination, standard rendering is fine.

5. Performance Monitoring

Add performance monitoring to track render counts (development only):

// Create src/utils/performance.ts
export function useRenderCount(componentName: string) {
    const renderCount = useRef(0);
    
    useEffect(() => {
        renderCount.current += 1;
        console.log(`${componentName} rendered ${renderCount.current} times`);
    });
}

// Usage in a component (remove in production):
function ProductCard({ product }: ProductCardProps) {
    useRenderCount('ProductCard');
    // ... rest of component
}

โœ… Performance Checklist

  • โœ… Memoize expensive computations with useMemo
  • โœ… Memoize callbacks with useCallback
  • โœ… Wrap pure components with React.memo
  • โœ… Debounce user input (especially search)
  • โœ… Avoid unnecessary state updates
  • โœ… Use proper dependency arrays
  • โœ… Profile with React DevTools

๐Ÿ‹๏ธ Exercise: Measure Performance Impact

Use React DevTools Profiler to measure the performance improvement from memoization.

๐Ÿ’ก Steps
  1. Install React DevTools browser extension
  2. Open Profiler tab
  3. Start recording
  4. Perform actions (add to cart, filter, search)
  5. Stop recording
  6. Compare render times before and after optimization
โœ… What to Look For
  • Fewer re-renders of memoized components
  • Shorter render duration overall
  • Components with gray (skipped) renders in the profiler
  • Reduced "wasted" renders

โœจ Step 10: Styling and Polish

Let's add the finishing touches to make our e-commerce application professional and visually appealing.

Enhanced Visual Design

We'll focus on:

  • ๐ŸŽจ Consistent color palette and spacing
  • ๐ŸŒˆ Smooth animations and transitions
  • ๐Ÿ“ฑ Perfect responsive behavior
  • โ™ฟ Improved accessibility
  • โœจ Micro-interactions for better UX

Global Styles and CSS Variables

Update your global styles for consistency. Create src/index.css:

/* CSS Variables for theming */
:root {
    /* Colors */
    --primary: #667eea;
    --primary-dark: #5568d3;
    --primary-light: #eef2ff;
    
    --secondary: #764ba2;
    
    --success: #48bb78;
    --success-light: #e8f5e9;
    
    --warning: #ffc107;
    --warning-light: #fff3cd;
    
    --danger: #e53e3e;
    --danger-light: #fed7d7;
    
    --info: #2196F3;
    --info-light: #e3f2fd;
    
    /* Neutrals */
    --gray-50: #f7fafc;
    --gray-100: #edf2f7;
    --gray-200: #e2e8f0;
    --gray-300: #cbd5e0;
    --gray-400: #a0aec0;
    --gray-500: #718096;
    --gray-600: #4a5568;
    --gray-700: #2d3748;
    --gray-800: #1a202c;
    --gray-900: #171923;
    
    /* Spacing */
    --spacing-xs: 0.25rem;
    --spacing-sm: 0.5rem;
    --spacing-md: 1rem;
    --spacing-lg: 1.5rem;
    --spacing-xl: 2rem;
    --spacing-2xl: 3rem;
    
    /* Border radius */
    --radius-sm: 6px;
    --radius-md: 8px;
    --radius-lg: 12px;
    --radius-xl: 16px;
    --radius-full: 9999px;
    
    /* Shadows */
    --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
    --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
    --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15);
    --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.2);
    
    /* Transitions */
    --transition-fast: 150ms ease;
    --transition-base: 200ms ease;
    --transition-slow: 300ms ease;
}

/* Global Resets and Base Styles */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html {
    scroll-behavior: smooth;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 
                 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
                 'Helvetica Neue', sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    color: var(--gray-700);
    background: var(--gray-50);
    line-height: 1.6;
}

/* Focus styles for accessibility */
*:focus-visible {
    outline: 3px solid var(--primary);
    outline-offset: 2px;
}

/* Button reset */
button {
    font-family: inherit;
    cursor: pointer;
}

/* Link styles */
a {
    color: var(--primary);
    text-decoration: none;
    transition: color var(--transition-fast);
}

a:hover {
    color: var(--primary-dark);
}

/* Selection color */
::selection {
    background: var(--primary-light);
    color: var(--primary-dark);
}

/* Scrollbar styling */
::-webkit-scrollbar {
    width: 10px;
    height: 10px;
}

::-webkit-scrollbar-track {
    background: var(--gray-100);
}

::-webkit-scrollbar-thumb {
    background: var(--gray-300);
    border-radius: var(--radius-full);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--gray-400);
}

Adding Loading States

Create a loading spinner component in src/components/LoadingSpinner.tsx:

import React from 'react';
import './LoadingSpinner.css';

interface LoadingSpinnerProps {
    size?: 'small' | 'medium' | 'large';
    message?: string;
}

function LoadingSpinner({ size = 'medium', message }: LoadingSpinnerProps) {
    return (
        <div className="loading-spinner-container">
            <div className={`loading-spinner ${size}`}>
                <div className="spinner"></div>
            </div>
            {message && <p className="loading-message">{message}</p>}
        </div>
    );
}

export default LoadingSpinner;
/* LoadingSpinner.css */
.loading-spinner-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--spacing-2xl);
}

.spinner {
    border: 4px solid var(--gray-200);
    border-top: 4px solid var(--primary);
    border-radius: var(--radius-full);
    animation: spin 1s linear infinite;
}

.loading-spinner.small .spinner {
    width: 24px;
    height: 24px;
    border-width: 3px;
}

.loading-spinner.medium .spinner {
    width: 48px;
    height: 48px;
}

.loading-spinner.large .spinner {
    width: 72px;
    height: 72px;
    border-width: 5px;
}

.loading-message {
    margin-top: var(--spacing-md);
    color: var(--gray-600);
    font-size: 0.875rem;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

Toast Notifications

Add user feedback for actions. Create src/components/Toast.tsx:

import React, { useEffect } from 'react';
import './Toast.css';

interface ToastProps {
    message: string;
    type?: 'success' | 'error' | 'info';
    duration?: number;
    onClose: () => void;
}

function Toast({ message, type = 'info', duration = 3000, onClose }: ToastProps) {
    useEffect(() => {
        const timer = setTimeout(onClose, duration);
        return () => clearTimeout(timer);
    }, [duration, onClose]);
    
    const icons = {
        success: 'โœ…',
        error: 'โŒ',
        info: 'โ„น๏ธ'
    };
    
    return (
        <div className={`toast toast-${type}`}>
            <span className="toast-icon">{icons[type]}</span>
            <span className="toast-message">{message}</span>
            <button onClick={onClose} className="toast-close">ร—</button>
        </div>
    );
}

export default Toast;
/* Toast.css */
.toast {
    position: fixed;
    bottom: var(--spacing-lg);
    right: var(--spacing-lg);
    min-width: 300px;
    padding: var(--spacing-md) var(--spacing-lg);
    background: white;
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-lg);
    display: flex;
    align-items: center;
    gap: var(--spacing-md);
    animation: slideIn var(--transition-base);
    z-index: 9999;
}

.toast-success {
    border-left: 4px solid var(--success);
}

.toast-error {
    border-left: 4px solid var(--danger);
}

.toast-info {
    border-left: 4px solid var(--info);
}

.toast-icon {
    font-size: 1.5rem;
    flex-shrink: 0;
}

.toast-message {
    flex: 1;
    color: var(--gray-700);
}

.toast-close {
    background: none;
    border: none;
    font-size: 1.5rem;
    color: var(--gray-400);
    width: 24px;
    height: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: color var(--transition-fast);
}

.toast-close:hover {
    color: var(--gray-600);
}

@keyframes slideIn {
    from {
        transform: translateX(100%);
        opacity: 0;
    }
    to {
        transform: translateX(0);
        opacity: 1;
    }
}

@media (max-width: 640px) {
    .toast {
        bottom: var(--spacing-md);
        right: var(--spacing-md);
        left: var(--spacing-md);
        min-width: auto;
    }
}

Micro-Interactions

Add subtle animations to enhance UX. Update button styles:

/* Enhanced button animations */
.add-to-cart-btn,
.checkout-btn,
.clear-cart-btn {
    position: relative;
    overflow: hidden;
    transition: all var(--transition-base);
}

.add-to-cart-btn:active,
.checkout-btn:active {
    transform: scale(0.98);
}

/* Ripple effect on click */
.add-to-cart-btn::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 0;
    height: 0;
    border-radius: var(--radius-full);
    background: rgba(255, 255, 255, 0.5);
    transform: translate(-50%, -50%);
    transition: width 0.6s, height 0.6s;
}

.add-to-cart-btn:active::after {
    width: 300px;
    height: 300px;
}

/* Pulse animation for cart badge */
.cart-badge {
    animation: pulse 2s infinite;
}

@keyframes pulse {
    0%, 100% {
        transform: scale(1);
    }
    50% {
        transform: scale(1.1);
    }
}

โœ… Polish Checklist

  • โœ… Consistent color scheme using CSS variables
  • โœ… Smooth transitions on interactive elements
  • โœ… Loading states for async operations
  • โœ… Toast notifications for user feedback
  • โœ… Micro-interactions (hover, active states)
  • โœ… Proper focus styles for accessibility
  • โœ… Custom scrollbar styling
  • โœ… Responsive design tested on all breakpoints

๐Ÿ‹๏ธ Exercise: Add a Toast System

Integrate the Toast component to show feedback when items are added/removed from the cart.

๐Ÿ’ก Hint

Create a ToastProvider context that manages an array of toasts. Add methods to show/hide toasts. Use it in CartContext to show feedback.

โœ… Solution Structure
// Create ToastContext
const ToastContext = createContext<{
    showToast: (message: string, type: 'success' | 'error') => void;
}>(null!);

// In addToCart function:
addToCart(product);
showToast(`${product.name} added to cart!`, 'success');

// In removeFromCart:
removeFromCart(id);
showToast('Item removed from cart', 'info');

๐Ÿงช Step 11: Testing and Refinement

Let's ensure our e-commerce application works correctly by adding comprehensive tests. We'll use React Testing Library to test our components and hooks.

Testing Strategy

graph TD A[Testing Pyramid] --> B[Unit Tests] A --> C[Integration Tests] A --> D[E2E Tests] B --> E[Reducer Logic] B --> F[Custom Hooks] B --> G[Utility Functions] C --> H[Component Interaction] C --> I[Context + Components] C --> J[User Workflows] D --> K[Full App Flow] style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#ed8936,color:#fff style D fill:#f6ad55,color:#fff

Setting Up Testing Environment

If you're using Vite, install testing dependencies:

# Install testing dependencies
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom

# Add to vite.config.ts:
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
  },
})

Test Setup File

Create src/test/setup.ts:

import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';

// Extend Vitest's expect with jest-dom matchers
expect.extend(matchers);

// Cleanup after each test
afterEach(() => {
    cleanup();
});

Testing the Cart Reducer

Create src/reducers/__tests__/CartReducer.test.ts:

import { describe, it, expect } from 'vitest';
import { cartReducer, initialCartState } from '../CartReducer';
import { Product } from '../../types';

// Mock product for testing
const mockProduct: Product = {
    id: 1,
    name: 'Test Product',
    price: 99.99,
    category: 'electronics',
    image: 'test.jpg',
    description: 'A test product',
    inStock: true,
    rating: 4.5
};

describe('cartReducer', () => {
    describe('ADD_TO_CART', () => {
        it('should add a new product to empty cart', () => {
            const action = { type: 'ADD_TO_CART' as const, payload: mockProduct };
            const result = cartReducer(initialCartState, action);
            
            expect(result.items).toHaveLength(1);
            expect(result.items[0].product).toEqual(mockProduct);
            expect(result.items[0].quantity).toBe(1);
            expect(result.total).toBe(99.99);
        });
        
        it('should increment quantity if product already exists', () => {
            const existingState = {
                items: [{ product: mockProduct, quantity: 1 }],
                total: 99.99
            };
            
            const action = { type: 'ADD_TO_CART' as const, payload: mockProduct };
            const result = cartReducer(existingState, action);
            
            expect(result.items).toHaveLength(1);
            expect(result.items[0].quantity).toBe(2);
            expect(result.total).toBe(199.98);
        });
    });
    
    describe('REMOVE_FROM_CART', () => {
        it('should remove product from cart', () => {
            const existingState = {
                items: [{ product: mockProduct, quantity: 2 }],
                total: 199.98
            };
            
            const action = { type: 'REMOVE_FROM_CART' as const, payload: 1 };
            const result = cartReducer(existingState, action);
            
            expect(result.items).toHaveLength(0);
            expect(result.total).toBe(0);
        });
        
        it('should handle removing non-existent product', () => {
            const action = { type: 'REMOVE_FROM_CART' as const, payload: 999 };
            const result = cartReducer(initialCartState, action);
            
            expect(result).toEqual(initialCartState);
        });
    });
    
    describe('UPDATE_QUANTITY', () => {
        it('should update product quantity', () => {
            const existingState = {
                items: [{ product: mockProduct, quantity: 1 }],
                total: 99.99
            };
            
            const action = { 
                type: 'UPDATE_QUANTITY' as const, 
                payload: { id: 1, quantity: 5 }
            };
            const result = cartReducer(existingState, action);
            
            expect(result.items[0].quantity).toBe(5);
            expect(result.total).toBe(499.95);
        });
        
        it('should remove item if quantity is 0 or less', () => {
            const existingState = {
                items: [{ product: mockProduct, quantity: 1 }],
                total: 99.99
            };
            
            const action = { 
                type: 'UPDATE_QUANTITY' as const, 
                payload: { id: 1, quantity: 0 }
            };
            const result = cartReducer(existingState, action);
            
            expect(result.items).toHaveLength(0);
            expect(result.total).toBe(0);
        });
    });
    
    describe('CLEAR_CART', () => {
        it('should clear all items from cart', () => {
            const existingState = {
                items: [
                    { product: mockProduct, quantity: 2 },
                    { product: { ...mockProduct, id: 2 }, quantity: 1 }
                ],
                total: 299.97
            };
            
            const action = { type: 'CLEAR_CART' as const };
            const result = cartReducer(existingState, action);
            
            expect(result).toEqual(initialCartState);
        });
    });
});

๐Ÿ“– Testing Best Practices

Arrange-Act-Assert: Set up state, perform action, verify result

Test Edge Cases: Empty states, boundary conditions, invalid inputs

Descriptive Names: Test names should explain what they verify

Testing Components with Context

Create src/components/__tests__/ProductCard.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { CartProvider } from '../../context/CartContext';
import ProductCard from '../ProductCard';
import { Product } from '../../types';

// Test helper to render with providers
function renderWithProviders(ui: React.ReactElement) {
    return render(
        <CartProvider>
            {ui}
        </CartProvider>
    );
}

const mockProduct: Product = {
    id: 1,
    name: 'Wireless Headphones',
    price: 299.99,
    category: 'electronics',
    image: 'https://example.com/image.jpg',
    description: 'Premium wireless headphones',
    inStock: true,
    rating: 4.8
};

describe('ProductCard', () => {
    it('should render product information', () => {
        renderWithProviders(<ProductCard product={mockProduct} />);
        
        expect(screen.getByText('Wireless Headphones')).toBeInTheDocument();
        expect(screen.getByText('Premium wireless headphones')).toBeInTheDocument();
        expect(screen.getByText('$299.99')).toBeInTheDocument();
        expect(screen.getByText('4.8')).toBeInTheDocument();
    });
    
    it('should show add to cart button for in-stock items', () => {
        renderWithProviders(<ProductCard product={mockProduct} />);
        
        const button = screen.getByRole('button', { name: /add to cart/i });
        expect(button).toBeEnabled();
    });
    
    it('should disable add to cart button for out-of-stock items', () => {
        const outOfStockProduct = { ...mockProduct, inStock: false };
        renderWithProviders(<ProductCard product={outOfStockProduct} />);
        
        const button = screen.getByRole('button', { name: /unavailable/i });
        expect(button).toBeDisabled();
    });
    
    it('should add product to cart when button is clicked', () => {
        renderWithProviders(<ProductCard product={mockProduct} />);
        
        const button = screen.getByRole('button', { name: /add to cart/i });
        fireEvent.click(button);
        
        // After clicking, should show quantity badge
        expect(screen.getByText('1 in cart')).toBeInTheDocument();
    });
    
    it('should show quantity badge when product is in cart', () => {
        renderWithProviders(
            <>
                <ProductCard product={mockProduct} />
            </>
        );
        
        // Add to cart
        const button = screen.getByRole('button', { name: /add to cart/i });
        fireEvent.click(button);
        fireEvent.click(button);
        
        // Should show quantity
        expect(screen.getByText('2 in cart')).toBeInTheDocument();
    });
});

Testing Custom Hooks

Create src/hooks/__tests__/useProductFilters.test.ts:

import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useProductFilters } from '../useProductFilters';
import { Product } from '../../types';

const mockProducts: Product[] = [
    {
        id: 1,
        name: 'Laptop',
        price: 999.99,
        category: 'electronics',
        image: 'laptop.jpg',
        description: 'Powerful laptop',
        inStock: true,
        rating: 4.5
    },
    {
        id: 2,
        name: 'Mouse',
        price: 49.99,
        category: 'accessories',
        image: 'mouse.jpg',
        description: 'Wireless mouse',
        inStock: true,
        rating: 4.2
    },
    {
        id: 3,
        name: 'Keyboard',
        price: 149.99,
        category: 'electronics',
        image: 'keyboard.jpg',
        description: 'Mechanical keyboard',
        inStock: false,
        rating: 4.8
    }
];

describe('useProductFilters', () => {
    it('should return all products initially', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        expect(result.current.filteredProducts).toHaveLength(3);
    });
    
    it('should filter by search query', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        act(() => {
            result.current.updateFilter('searchQuery', 'laptop');
        });
        
        expect(result.current.filteredProducts).toHaveLength(1);
        expect(result.current.filteredProducts[0].name).toBe('Laptop');
    });
    
    it('should filter by category', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        act(() => {
            result.current.updateFilter('category', 'accessories');
        });
        
        expect(result.current.filteredProducts).toHaveLength(1);
        expect(result.current.filteredProducts[0].category).toBe('accessories');
    });
    
    it('should filter by price range', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        act(() => {
            result.current.updateFilter('priceRange', { min: 100, max: 500 });
        });
        
        expect(result.current.filteredProducts).toHaveLength(1);
        expect(result.current.filteredProducts[0].name).toBe('Keyboard');
    });
    
    it('should filter out of stock items', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        act(() => {
            result.current.updateFilter('showOutOfStock', false);
        });
        
        expect(result.current.filteredProducts).toHaveLength(2);
        expect(result.current.filteredProducts.every(p => p.inStock)).toBe(true);
    });
    
    it('should sort products by price', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        act(() => {
            result.current.setSortBy('price-low');
        });
        
        const prices = result.current.filteredProducts.map(p => p.price);
        expect(prices).toEqual([49.99, 149.99, 999.99]);
    });
    
    it('should reset filters', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        act(() => {
            result.current.updateFilter('searchQuery', 'laptop');
            result.current.updateFilter('category', 'electronics');
            result.current.setSortBy('price-high');
        });
        
        act(() => {
            result.current.resetFilters();
        });
        
        expect(result.current.filters.searchQuery).toBe('');
        expect(result.current.filters.category).toBe('all');
        expect(result.current.sortBy).toBe('name');
        expect(result.current.filteredProducts).toHaveLength(3);
    });
    
    it('should calculate active filter count', () => {
        const { result } = renderHook(() => useProductFilters(mockProducts));
        
        expect(result.current.activeFilterCount).toBe(0);
        
        act(() => {
            result.current.updateFilter('searchQuery', 'laptop');
            result.current.updateFilter('category', 'electronics');
        });
        
        expect(result.current.activeFilterCount).toBe(2);
    });
});

๐Ÿ’ก Testing Hooks

renderHook: Special utility from Testing Library for testing hooks in isolation

act(): Wraps state updates to ensure React processes them before assertions

result.current: Access the current return value of the hook

Integration Test: Complete User Flow

Create src/__tests__/ShoppingFlow.integration.test.tsx:

import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';

describe('Shopping Flow Integration', () => {
    it('should complete a full shopping workflow', async () => {
        const user = userEvent.setup();
        render(<App />);
        
        // 1. Verify initial state
        expect(screen.getByText(/tech store/i)).toBeInTheDocument();
        expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
        
        // 2. Search for a product
        const searchInput = screen.getByPlaceholderText(/search products/i);
        await user.type(searchInput, 'headphones');
        
        // Wait for debounce and check filtered results
        await new Promise(resolve => setTimeout(resolve, 400));
        const productCards = screen.getAllByRole('article');
        expect(productCards.length).toBeGreaterThan(0);
        
        // 3. Add product to cart
        const addButton = screen.getByRole('button', { name: /add to cart/i });
        await user.click(addButton);
        
        // 4. Verify cart updated
        expect(screen.getByText('1 in cart')).toBeInTheDocument();
        expect(screen.queryByText(/your cart is empty/i)).not.toBeInTheDocument();
        
        // 5. Add same product again
        await user.click(addButton);
        expect(screen.getByText('2 in cart')).toBeInTheDocument();
        
        // 6. Filter by category
        const categorySelect = screen.getByLabelText(/category/i);
        await user.selectOptions(categorySelect, 'electronics');
        
        // 7. Update quantity in cart
        const quantityInput = screen.getByRole('spinbutton', { name: /quantity/i });
        await user.clear(quantityInput);
        await user.type(quantityInput, '5');
        
        // 8. Verify total updated
        const cartSummary = screen.getByText(/total:/i).closest('div');
        expect(cartSummary).toHaveTextContent(/\$.*\d+/);
        
        // 9. Remove item from cart
        const removeButton = screen.getByRole('button', { name: /remove/i });
        await user.click(removeButton);
        
        // Confirm removal in dialog
        // Note: This depends on how you handle the confirm dialog
        
        // 10. Verify cart is empty again
        // expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
    });
    
    it('should handle filtering and sorting together', async () => {
        const user = userEvent.setup();
        render(<App />);
        
        // Apply category filter
        const categorySelect = screen.getByLabelText(/category/i);
        await user.selectOptions(categorySelect, 'electronics');
        
        // Apply sort
        const sortSelect = screen.getByLabelText(/sort by/i);
        await user.selectOptions(sortSelect, 'price-low');
        
        // Verify results are filtered and sorted
        const productCards = screen.getAllByRole('article');
        expect(productCards.length).toBeGreaterThan(0);
        
        // Get all prices and verify they're in ascending order
        const prices = Array.from(productCards).map(card => {
            const priceText = within(card).getByText(/\$\d+/).textContent;
            return parseFloat(priceText?.replace('$', '') || '0');
        });
        
        for (let i = 1; i < prices.length; i++) {
            expect(prices[i]).toBeGreaterThanOrEqual(prices[i - 1]);
        }
    });
});

Running Tests

Add scripts to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

โœ… Testing Checklist

  • โœ… Reducer logic (pure functions)
  • โœ… Custom hooks with renderHook
  • โœ… Component rendering and interactions
  • โœ… Context providers and consumers
  • โœ… User workflows (integration tests)
  • โœ… Edge cases and error states
  • โœ… Accessibility with screen reader queries

๐Ÿ‹๏ธ Exercise: Write Your Own Test

Write a test for the CartItem component that verifies quantity can be updated using the increment/decrement buttons.

๐Ÿ’ก Hint

Render CartItem within CartProvider, click the increment button, and verify the quantity updates. Use screen.getByLabelText to find buttons by their aria-labels.

โœ… Solution
it('should increment quantity when + button is clicked', async () => {
    const user = userEvent.setup();
    const mockItem = {
        product: mockProduct,
        quantity: 1
    };
    
    renderWithProviders(<CartItem item={mockItem} />);
    
    const incrementBtn = screen.getByLabelText('Increase quantity');
    await user.click(incrementBtn);
    
    const quantityInput = screen.getByLabelText('Quantity');
    expect(quantityInput).toHaveValue(2);
});

๐ŸŽ Step 12: Bonus Features

Let's explore additional features you can add to take your e-commerce application to the next level!

Bonus Feature Ideas

mindmap root((E-commerce
Enhancements)) Wishlist Save favorites Share lists Price alerts Product Reviews Star ratings Written reviews Helpful votes User Accounts Authentication Order history Saved addresses Payment Multiple methods Saved cards Invoice generation Advanced Search Faceted filters Auto-suggest Recent searches Recommendations Similar products Frequently bought Personalized

1. Wishlist Feature

Add ability to save favorite products:

// src/context/WishlistContext.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';
import { Product } from '../types';

interface WishlistContextValue {
    wishlist: Product[];
    addToWishlist: (product: Product) => void;
    removeFromWishlist: (productId: number) => void;
    isInWishlist: (productId: number) => boolean;
    clearWishlist: () => void;
}

const WishlistContext = createContext<WishlistContextValue | undefined>(undefined);

export function WishlistProvider({ children }: { children: React.ReactNode }) {
    const [wishlist, setWishlist] = useState<Product[]>([]);
    
    const addToWishlist = useCallback((product: Product) => {
        setWishlist(prev => {
            if (prev.find(p => p.id === product.id)) {
                return prev; // Already in wishlist
            }
            return [...prev, product];
        });
    }, []);
    
    const removeFromWishlist = useCallback((productId: number) => {
        setWishlist(prev => prev.filter(p => p.id !== productId));
    }, []);
    
    const isInWishlist = useCallback((productId: number) => {
        return wishlist.some(p => p.id === productId);
    }, [wishlist]);
    
    const clearWishlist = useCallback(() => {
        setWishlist([]);
    }, []);
    
    return (
        <WishlistContext.Provider value={{
            wishlist,
            addToWishlist,
            removeFromWishlist,
            isInWishlist,
            clearWishlist
        }}>
            {children}
        </WishlistContext.Provider>
    );
}

export function useWishlist() {
    const context = useContext(WishlistContext);
    if (!context) {
        throw new Error('useWishlist must be used within WishlistProvider');
    }
    return context;
}

// Add to ProductCard:
const { addToWishlist, isInWishlist } = useWishlist();
const inWishlist = isInWishlist(product.id);

<button onClick={() => addToWishlist(product)}>
    {inWishlist ? 'โค๏ธ' : '๐Ÿค'} Wishlist
</button>

2. Product Comparison

Allow users to compare multiple products side-by-side:

// src/components/ProductComparison.tsx
import React from 'react';
import { Product } from '../types';
import './ProductComparison.css';

interface ProductComparisonProps {
    products: Product[];
    onRemove: (id: number) => void;
    onClose: () => void;
}

function ProductComparison({ products, onRemove, onClose }: ProductComparisonProps) {
    if (products.length === 0) return null;
    
    return (
        <div className="comparison-modal">
            <div className="comparison-header">
                <h2>โš–๏ธ Compare Products</h2>
                <button onClick={onClose}>ร—</button>
            </div>
            
            <div className="comparison-grid">
                {products.map(product => (
                    <div key={product.id} className="comparison-item">
                        <button 
                            onClick={() => onRemove(product.id)}
                            className="remove-comparison"
                        >
                            ร—
                        </button>
                        <img src={product.image} alt={product.name} />
                        <h3>{product.name}</h3>
                        
                        <table className="comparison-table">
                            <tbody>
                                <tr>
                                    <td>Price</td>
                                    <td>${product.price.toFixed(2)}</td>
                                </tr>
                                <tr>
                                    <td>Rating</td>
                                    <td>{'โญ'.repeat(Math.floor(product.rating))}</td>
                                </tr>
                                <tr>
                                    <td>Category</td>
                                    <td>{product.category}</td>
                                </tr>
                                <tr>
                                    <td>Stock</td>
                                    <td>{product.inStock ? 'โœ… In Stock' : 'โŒ Out'}</td>
                                </tr>
                            </tbody>
                        </table>
                    </div>
                ))}
            </div>
        </div>
    );
}

3. Product Quick View Modal

Show detailed product info without leaving the catalog:

// src/components/ProductQuickView.tsx
import React from 'react';
import { Product } from '../types';
import { useCartContext } from '../context/CartContext';
import './ProductQuickView.css';

interface ProductQuickViewProps {
    product: Product;
    onClose: () => void;
}

function ProductQuickView({ product, onClose }: ProductQuickViewProps) {
    const { addToCart } = useCartContext();
    
    const handleAddToCart = () => {
        addToCart(product);
        onClose();
    };
    
    return (
        <div className="modal-overlay" onClick={onClose}>
            <div className="quick-view-modal" onClick={e => e.stopPropagation()}>
                <button className="modal-close" onClick={onClose}>ร—</button>
                
                <div className="quick-view-content">
                    <div className="quick-view-image">
                        <img src={product.image} alt={product.name} />
                    </div>
                    
                    <div className="quick-view-details">
                        <h2>{product.name}</h2>
                        
                        <div className="quick-view-rating">
                            {'โญ'.repeat(Math.floor(product.rating))}
                            <span>{product.rating} / 5</span>
                        </div>
                        
                        <p className="quick-view-price">${product.price.toFixed(2)}</p>
                        
                        <p className="quick-view-description">
                            {product.description}
                        </p>
                        
                        <div className="quick-view-meta">
                            <span>Category: {product.category}</span>
                            <span>
                                {product.inStock ? 'โœ… In Stock' : 'โŒ Out of Stock'}
                            </span>
                        </div>
                        
                        <button 
                            onClick={handleAddToCart}
                            disabled={!product.inStock}
                            className="quick-view-add-btn"
                        >
                            ๐Ÿ›’ Add to Cart
                        </button>
                    </div>
                </div>
            </div>
        </div>
    );
}

4. Recently Viewed Products

Track and display recently viewed items:

// src/hooks/useRecentlyViewed.ts
import { useState, useEffect } from 'react';
import { Product } from '../types';

const STORAGE_KEY = 'recently-viewed';
const MAX_ITEMS = 10;

export function useRecentlyViewed() {
    const [recentlyViewed, setRecentlyViewed] = useState<Product[]>([]);
    
    // Load from localStorage on mount
    useEffect(() => {
        const stored = localStorage.getItem(STORAGE_KEY);
        if (stored) {
            try {
                setRecentlyViewed(JSON.parse(stored));
            } catch (error) {
                console.error('Failed to parse recently viewed:', error);
            }
        }
    }, []);
    
    const addToRecentlyViewed = (product: Product) => {
        setRecentlyViewed(prev => {
            // Remove if already exists
            const filtered = prev.filter(p => p.id !== product.id);
            // Add to beginning
            const updated = [product, ...filtered].slice(0, MAX_ITEMS);
            // Save to localStorage
            localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
            return updated;
        });
    };
    
    const clearRecentlyViewed = () => {
        setRecentlyViewed([]);
        localStorage.removeItem(STORAGE_KEY);
    };
    
    return {
        recentlyViewed,
        addToRecentlyViewed,
        clearRecentlyViewed
    };
}

5. Discount Codes

Add promotional discount code functionality:

// src/hooks/useDiscountCode.ts
import { useState, useCallback } from 'react';

interface DiscountCode {
    code: string;
    discount: number; // percentage
    minPurchase?: number;
}

const VALID_CODES: DiscountCode[] = [
    { code: 'SAVE10', discount: 10 },
    { code: 'SAVE20', discount: 20, minPurchase: 100 },
    { code: 'FIRST50', discount: 50, minPurchase: 50 }
];

export function useDiscountCode() {
    const [appliedCode, setAppliedCode] = useState<DiscountCode | null>(null);
    const [error, setError] = useState<string>('');
    
    const applyCode = useCallback((code: string, cartTotal: number) => {
        const discountCode = VALID_CODES.find(
            c => c.code.toLowerCase() === code.toLowerCase()
        );
        
        if (!discountCode) {
            setError('Invalid discount code');
            return false;
        }
        
        if (discountCode.minPurchase && cartTotal < discountCode.minPurchase) {
            setError(`Minimum purchase of $${discountCode.minPurchase} required`);
            return false;
        }
        
        setAppliedCode(discountCode);
        setError('');
        return true;
    }, []);
    
    const removeCode = useCallback(() => {
        setAppliedCode(null);
        setError('');
    }, []);
    
    const calculateDiscount = useCallback((cartTotal: number) => {
        if (!appliedCode) return 0;
        return (cartTotal * appliedCode.discount) / 100;
    }, [appliedCode]);
    
    return {
        appliedCode,
        error,
        applyCode,
        removeCode,
        calculateDiscount
    };
}

โœ… Bonus Features Summary

  • โค๏ธ Wishlist with persistence
  • โš–๏ธ Product comparison tool
  • ๐Ÿ‘๏ธ Quick view modal
  • ๐Ÿ• Recently viewed tracking
  • ๐ŸŽซ Discount code system
  • ๐Ÿ“Š Product reviews and ratings
  • ๐Ÿ“ง Email wishlist sharing
  • ๐Ÿ”” Price drop notifications

๐Ÿ‹๏ธ Exercise: Implement One Bonus Feature

Choose one bonus feature and implement it in your application. Start with the wishlist as it's the most straightforward.

๐Ÿ’ก Implementation Steps
  1. Create the context or hook
  2. Add UI elements (buttons, icons)
  3. Implement persistence (localStorage)
  4. Add visual feedback
  5. Write tests

Deployment Preparation

Before deploying, ensure:

# Build for production
npm run build

# Test the production build locally
npm run preview

# Check bundle size
npm run build -- --report

# Run all tests
npm test

# Check for TypeScript errors
npm run type-check

โš ๏ธ Pre-Deployment Checklist

  • โœ… All tests passing
  • โœ… No TypeScript errors
  • โœ… Environment variables configured
  • โœ… Analytics and error tracking setup
  • โœ… SEO meta tags added
  • โœ… Performance optimized (Lighthouse score)
  • โœ… Accessibility audit passed
  • โœ… Cross-browser testing completed
  • โœ… Mobile responsiveness verified

๐ŸŽ‰ Project Summary and Next Steps

Congratulations! You've built a comprehensive e-commerce product catalog with shopping cart functionality using advanced React and TypeScript patterns.

What You've Learned

๐ŸŽ“ Module 5 Mastery

You've successfully implemented all advanced hooks and patterns:

  • useReducer: Complex state management for cart operations
  • useContext: Global state sharing without prop drilling
  • useRef: DOM access and mutable value storage
  • useMemo: Performance optimization for expensive calculations
  • useCallback: Memoized functions to prevent re-renders
  • Custom Hooks: Reusable logic extraction
  • Compound Components: Flexible component composition

Application Architecture

graph TB A[App.tsx] --> B[CartProvider] B --> C[ProductCatalog] B --> D[ShoppingCart] C --> E[FilterBar] C --> F[ProductGrid] F --> G[ProductCard] D --> H[CartItem] I[useProductFilters] -.-> C J[useDebounce] -.-> I K[cartReducer] -.-> B style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style I fill:#ed8936,color:#fff

Key Features Implemented

Feature Technology Benefit
Shopping Cart useReducer + Context Predictable state updates
Product Filtering useMemo + Custom Hook Optimized performance
Search useDebounce Hook Reduced computations
Cart Persistence localStorage + useEffect User convenience
Type Safety TypeScript Fewer runtime errors
Testing Vitest + Testing Library Confidence in changes

Best Practices Applied

โœ… Professional Standards

  • Separation of Concerns: Logic in hooks, UI in components
  • Single Responsibility: Each component has one clear purpose
  • DRY Principle: Reusable custom hooks and components
  • Type Safety: Comprehensive TypeScript usage
  • Performance: Memoization and lazy loading
  • Accessibility: ARIA labels and keyboard navigation
  • Testing: Unit, integration, and user flow tests
  • User Experience: Loading states, error handling, feedback

Next Steps and Enhancements

๐Ÿš€ Take It Further

Immediate Next Steps:

  1. Deploy to Vercel or Netlify
  2. Add one bonus feature (wishlist recommended)
  3. Integrate with a real backend API
  4. Implement user authentication
  5. Add checkout flow with Stripe

Advanced Enhancements:

  • Server-side rendering with Next.js
  • Progressive Web App (PWA) features
  • Real-time inventory updates with WebSockets
  • AI-powered product recommendations
  • Multi-language support (i18n)
  • Advanced analytics and tracking

Continuing Your React Journey

You're now ready to move forward in the course:

  • Module 6: Routing and Navigation with React Router
  • Module 7: Forms and Validation
  • Module 8: State Management with Zustand/Redux
  • Module 9: Testing React Applications
  • Module 10: Deployment and Production

๐Ÿ’ก Key Takeaways

Remember these core concepts:

  • useReducer shines when state transitions follow predictable patterns
  • Context + Reducer is a powerful alternative to Redux for medium apps
  • Memoization (useMemo, useCallback) prevents performance issues
  • Custom hooks make logic reusable and testable
  • TypeScript catches errors before they reach production
  • Testing gives confidence to refactor and add features

Resources for Continued Learning

โญ Project Showcase

Don't forget to:

  • Add this project to your portfolio
  • Write a blog post about what you learned
  • Share on LinkedIn or Twitter
  • Get feedback from other developers
  • Keep iterating and improving!

Final Thoughts

Building this e-commerce application has given you hands-on experience with:

  • โœ… Complex state management patterns
  • โœ… Performance optimization techniques
  • โœ… Professional component architecture
  • โœ… TypeScript in real-world scenarios
  • โœ… Testing strategies that matter
  • โœ… User experience best practices

These patterns and techniques apply to any React application you'll build in the future. You're well-equipped to tackle real-world projects!

๐ŸŽ‰ Congratulations!

You've completed Module 5!

You're now a master of advanced React hooks and patterns.