Build a Redux-powered E-commerce Shopping Cart

Weekend Project - Week 6

Project Overview

In this weekend project, you'll apply your understanding of Redux to build a fully-functional e-commerce shopping cart system. This project brings together everything you've learned this week about Redux principles, state management, actions, reducers, React-Redux integration, middleware, Redux Toolkit, and advanced patterns. By the end of this project, you'll have a solid understanding of how Redux manages state in a real-world application and how it makes complex state interactions more predictable and maintainable.

Learning Objectives

George Polya's 4-Step Problem Solving Method

Step 1: Understand the Problem

Let's break down what we need to build:

graph TD A[User Interface] --> B[Product List] A --> C[Shopping Cart] B --> D[Product Cards] C --> E[Cart Items] C --> F[Cart Summary] D --> G[Add to Cart] E --> H[Update Quantity] E --> I[Remove Item] F --> J[Calculate Total]

Step 2: Devise a Plan

Let's create a step-by-step plan to tackle this project:

  1. Set up project structure with create-react-app and Redux dependencies
  2. Design the Redux store structure and normalize state
  3. Create action types, action creators, and reducers
  4. Set up Redux Thunk for asynchronous API calls
  5. Build UI components (ProductList, ProductCard, Cart, CartItem, CartSummary)
  6. Connect components to Redux
  7. Implement persistent cart with localStorage middleware
  8. Add loading states and error handling
  9. Style the application and ensure responsive design
  10. Test functionality and fix any bugs

Step 3: Execute the Plan

Now, let's start implementing the plan:

Project Setup

First, let's create our React application and install the necessary dependencies:

npx create-react-app redux-shopping-cart
cd redux-shopping-cart
npm install redux react-redux @reduxjs/toolkit axios

Project Structure

Let's set up a clean project structure to organize our code:

src/
  ├── components/     # React components
  │   ├── Cart/
  │   ├── Products/
  │   └── UI/
  ├── features/       # Redux slices (using Redux Toolkit)
  │   ├── cart/
  │   ├── products/
  │   └── ui/
  ├── services/       # API services
  ├── store/          # Redux store setup
  ├── utils/          # Helper functions
  ├── App.js
  └── index.js

1. Redux Store Design

Let's design our Redux store with a normalized state shape for efficiency:

graph TD A[Redux Store] --> B[products] A --> C[cart] A --> D[ui] B --> E[productsById] B --> F[allProductIds] B --> G[status: loading/error] C --> H[items] C --> I[itemsCount] C --> J[totalAmount] D --> K[notifications] D --> L[activeFilters]

Setting Up the Redux Store

Create a Redux store with Redux Toolkit:

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import productsReducer from '../features/products/productsSlice';
import cartReducer from '../features/cart/cartSlice';
import uiReducer from '../features/ui/uiSlice';

// Load cart state from localStorage
const loadCartState = () => {
  try {
    const serializedState = localStorage.getItem('cart');
    if (serializedState === null) {
      return undefined;
    }
    return JSON.parse(serializedState);
  } catch (err) {
    console.error('Error loading cart state:', err);
    return undefined;
  }
};

// Save cart state to localStorage
const saveCartState = (state) => {
  try {
    const serializedState = JSON.stringify(state);
    localStorage.setItem('cart', serializedState);
  } catch (err) {
    console.error('Error saving cart state:', err);
  }
};

// Create the Redux store
const store = configureStore({
  reducer: {
    products: productsReducer,
    cart: cartReducer,
    ui: uiReducer,
  },
  preloadedState: {
    cart: loadCartState(),
  },
});

// Subscribe to store changes to save cart state
store.subscribe(() => {
  const state = store.getState();
  saveCartState(state.cart);
});

export default store;

2. Products Feature

Let's create our products slice with Redux Toolkit:

// src/features/products/productsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

// Async thunk for fetching products
export const fetchProducts = createAsyncThunk(
  'products/fetchProducts',
  async (_, { rejectWithValue }) => {
    try {
      // Using a free API for demo products
      const response = await axios.get('https://fakestoreapi.com/products');
      return response.data;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

// Initial state with normalized structure
const initialState = {
  productsById: {},
  allProductIds: [],
  status: 'idle', // idle, loading, succeeded, failed
  error: null,
};

// Products slice
const productsSlice = createSlice({
  name: 'products',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        // Normalize the data
        state.allProductIds = action.payload.map(product => product.id);
        action.payload.forEach(product => {
          state.productsById[product.id] = product;
        });
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload;
      });
  },
});

// Selectors
export const selectAllProducts = (state) => {
  return state.products.allProductIds.map(id => state.products.productsById[id]);
};

export const selectProductById = (state, productId) => {
  return state.products.productsById[productId];
};

export const selectProductsStatus = (state) => state.products.status;
export const selectProductsError = (state) => state.products.error;

export default productsSlice.reducer;

3. Cart Feature

Now, let's implement our cart slice:

// src/features/cart/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  items: {}, // Object with product IDs as keys
  itemCount: 0,
  totalAmount: 0,
};

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addToCart: (state, action) => {
      const { id, price } = action.payload;
      
      // If item is already in cart, increase quantity
      if (state.items[id]) {
        state.items[id].quantity += 1;
      } else {
        // If item is not in cart, add it
        state.items[id] = {
          ...action.payload,
          quantity: 1,
        };
      }
      
      // Update cart summary
      state.itemCount += 1;
      state.totalAmount += price;
    },
    
    removeFromCart: (state, action) => {
      const id = action.payload;
      const { price, quantity } = state.items[id];
      
      // Update cart summary
      state.itemCount -= quantity;
      state.totalAmount -= price * quantity;
      
      // Remove the item
      delete state.items[id];
    },
    
    updateQuantity: (state, action) => {
      const { id, quantity } = action.payload;
      const currentQuantity = state.items[id].quantity;
      const price = state.items[id].price;
      
      // Update cart summary
      state.itemCount += quantity - currentQuantity;
      state.totalAmount += price * (quantity - currentQuantity);
      
      // Update item quantity or remove if quantity is 0
      if (quantity <= 0) {
        delete state.items[id];
      } else {
        state.items[id].quantity = quantity;
      }
    },
    
    clearCart: (state) => {
      return initialState;
    },
  },
});

// Export actions
export const { addToCart, removeFromCart, updateQuantity, clearCart } = cartSlice.actions;

// Selectors
export const selectCartItems = (state) => {
  // Convert object to array for easier rendering
  return Object.values(state.items);
};

export const selectCartItemCount = (state) => state.cart.itemCount;
export const selectCartTotal = (state) => state.cart.totalAmount;
export const selectItemQuantity = (state, id) => state.cart.items[id]?.quantity || 0;

export default cartSlice.reducer;

4. UI Components

Let's create our React components for products and cart:

Product List Component

// src/components/Products/ProductList.js
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchProducts, selectAllProducts, selectProductsStatus, selectProductsError } from '../../features/products/productsSlice';
import ProductCard from './ProductCard';

const ProductList = () => {
  const dispatch = useDispatch();
  const products = useSelector(selectAllProducts);
  const status = useSelector(selectProductsStatus);
  const error = useSelector(selectProductsError);
  
  useEffect(() => {
    // Only fetch products if we haven't loaded them yet
    if (status === 'idle') {
      dispatch(fetchProducts());
    }
  }, [status, dispatch]);
  
  if (status === 'loading') {
    return <div className="loading">Loading products...</div>;
  }
  
  if (status === 'failed') {
    return <div className="error">Error: {error}</div>;
  }
  
  return (
    <div className="product-list">
      <h2>Products</h2>
      <div className="product-grid">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

export default ProductList;

Product Card Component

// src/components/Products/ProductCard.js
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { addToCart, selectItemQuantity } from '../../features/cart/cartSlice';

const ProductCard = ({ product }) => {
  const dispatch = useDispatch();
  const quantity = useSelector(state => selectItemQuantity(state, product.id));
  
  const handleAddToCart = () => {
    dispatch(addToCart(product));
  };
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.title} className="product-image" />
      <h3 className="product-title">{product.title}</h3>
      <p className="product-price">${product.price.toFixed(2)}</p>
      <p className="product-category">{product.category}</p>
      <button 
        className="add-to-cart-btn" 
        onClick={handleAddToCart}
      >
        Add to Cart {quantity > 0 && `(${quantity})`}
      </button>
    </div>
  );
};

export default React.memo(ProductCard);

Shopping Cart Component

// src/components/Cart/ShoppingCart.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectCartItems, selectCartTotal, clearCart } from '../../features/cart/cartSlice';
import CartItem from './CartItem';

const ShoppingCart = () => {
  const dispatch = useDispatch();
  const cartItems = useSelector(selectCartItems);
  const totalAmount = useSelector(selectCartTotal);
  
  const handleClearCart = () => {
    if (window.confirm('Are you sure you want to clear your cart?')) {
      dispatch(clearCart());
    }
  };
  
  if (cartItems.length === 0) {
    return (
      <div className="shopping-cart">
        <h2>Your Cart</h2>
        <p>Your cart is empty</p>
      </div>
    );
  }
  
  return (
    <div className="shopping-cart">
      <h2>Your Cart</h2>
      <div className="cart-items">
        {cartItems.map(item => (
          <CartItem key={item.id} item={item} />
        ))}
      </div>
      <div className="cart-summary">
        <div className="cart-total">
          <h3>Total: ${totalAmount.toFixed(2)}</h3>
        </div>
        <div className="cart-actions">
          <button className="clear-cart-btn" onClick={handleClearCart}>
            Clear Cart
          </button>
          <button className="checkout-btn">
            Checkout
          </button>
        </div>
      </div>
    </div>
  );
};

export default ShoppingCart;

Cart Item Component

// src/components/Cart/CartItem.js
import React from 'react';
import { useDispatch } from 'react-redux';
import { updateQuantity, removeFromCart } from '../../features/cart/cartSlice';

const CartItem = ({ item }) => {
  const dispatch = useDispatch();
  
  const handleQuantityChange = (e) => {
    const quantity = parseInt(e.target.value, 10);
    dispatch(updateQuantity({ id: item.id, quantity }));
  };
  
  const handleRemove = () => {
    dispatch(removeFromCart(item.id));
  };
  
  return (
    <div className="cart-item">
      <img 
        src={item.image} 
        alt={item.title} 
        className="cart-item-image"
      />
      <div className="cart-item-details">
        <h4 className="cart-item-title">{item.title}</h4>
        <p className="cart-item-price">${item.price.toFixed(2)}</p>
      </div>
      <div className="cart-item-actions">
        <div className="quantity-control">
          <button 
            onClick={() => dispatch(updateQuantity({ id: item.id, quantity: item.quantity - 1 }))}
            disabled={item.quantity <= 1}
          >
            -
          </button>
          <input 
            type="number" 
            value={item.quantity} 
            onChange={handleQuantityChange}
            min="1"
          />
          <button 
            onClick={() => dispatch(updateQuantity({ id: item.id, quantity: item.quantity + 1 }))}
          >
            +
          </button>
        </div>
        <button className="remove-btn" onClick={handleRemove}>
          Remove
        </button>
      </div>
      <div className="cart-item-subtotal">
        ${(item.price * item.quantity).toFixed(2)}
      </div>
    </div>
  );
};

export default React.memo(CartItem);

App Component

// src/App.js
import React from 'react';
import { Provider } from 'react-redux';
import store from './store';
import ProductList from './components/Products/ProductList';
import ShoppingCart from './components/Cart/ShoppingCart';
import './App.css';

function App() {
  return (
    <Provider store={store}>
      <div className="app">
        <header className="app-header">
          <h1>Redux Shopping Cart</h1>
        </header>
        <main className="app-main">
          <div className="container">
            <div className="row">
              <div className="col-products">
                <ProductList />
              </div>
              <div className="col-cart">
                <ShoppingCart />
              </div>
            </div>
          </div>
        </main>
      </div>
    </Provider>
  );
}

export default App;

Understanding Redux Flow in the Shopping Cart

flowchart TB subgraph "User Interface" UI[User Clicks "Add to Cart"] end subgraph "Action Creator" AC[addToCart Action Creator] end subgraph "Middleware" MW[Redux Thunk] end subgraph "Reducer" R[Cart Reducer] end subgraph "Store" S[Redux Store] end subgraph "Persistence" P[localStorage] end UI --> AC AC --> MW MW --> R R --> S S --> P S ---> UI

How Redux Manages Shopping Cart State

Let's break down how Redux manages the shopping cart state in our application:

  1. User Action: When a user clicks "Add to Cart", a Redux action is dispatched.
  2. Action Processing: The action goes through any middleware (like Redux Thunk for async operations).
  3. Reducer Update: The cart reducer processes the action and updates the state accordingly.
  4. State Changes: The Redux store is updated with the new state.
  5. Persistence: Our store subscriber saves the cart state to localStorage.
  6. UI Updates: Components connected to the store via useSelector re-render with the new data.

This flow ensures that our shopping cart state is:

Real-World Application

E-commerce shopping carts are excellent examples of complex state management scenarios:

Major e-commerce platforms like Amazon, Shopify stores, and Walmart all use similar patterns to what we've built, but at a much larger scale with additional features:

Understanding how to build a Redux-powered shopping cart gives you insight into how these large-scale applications manage their complex state.

Step 4: Review and Reflect

Common Challenges and Solutions

Challenge Solution
Cart items disappear on refresh Implement localStorage persistence as shown in our store setup
Inefficient re-renders Use React.memo for components and implement proper selector functions
Complex state updates Leverage Redux Toolkit's immer integration for simpler updates
API failures Implement proper error handling in async thunks
Cart synchronization with backend Add middleware to sync cart changes with your backend API

Enhancement Ideas

Project Requirements

Your weekend project should include:

Submission Guidelines

Conclusion

Building a Redux-powered e-commerce shopping cart is an excellent way to solidify your understanding of Redux state management. This project combines many real-world requirements that you'll encounter in professional development:

Remember that Redux is just one approach to state management. While it's powerful for complex applications, simpler solutions like React Context or local component state might be more appropriate for smaller projects. The skills you've learned this week will help you make informed decisions about state management in your future React applications.

Happy coding, and don't hesitate to reach out if you have questions or challenges with your implementation!

Additional Resources