๐๏ธ 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.
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:
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
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:
- Add products to cart and verify quantities update
- Use search to filter products
- Switch between category filters
- Sort products by different criteria
- Update quantities in the cart
- Remove items from cart
- Clear entire cart
- Test on mobile viewport (use browser dev tools)
- 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:
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
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
- Install React DevTools browser extension
- Open Profiler tab
- Start recording
- Perform actions (add to cart, filter, search)
- Stop recording
- 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
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
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
- Create the context or hook
- Add UI elements (buttons, icons)
- Implement persistence (localStorage)
- Add visual feedback
- 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
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:
- Deploy to Vercel or Netlify
- Add one bonus feature (wishlist recommended)
- Integrate with a real backend API
- Implement user authentication
- 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
- ๐ React Documentation - Official docs
- ๐ TypeScript Handbook
- ๐ React Testing Library
- ๐ฅ Egghead.io - Video tutorials
- ๐ฌ Reactiflux - Discord community
- ๐ React TypeScript Cheatsheet
โญ 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.