Build a Multi-page React Application with Routing and Performance Optimizations

Project Overview

In this weekend project, we'll build a multi-page React application featuring a movie database with routing and performance optimizations. The app will include:

Project Architecture

graph TD A[App.js] --> B[Router Setup] B --> C[Layout Component] C --> D[Navigation] C --> E[Pages] E --> F[Home Page] E --> G[Movies Page] E --> H[Movie Detail Page] E --> I[About Page] G --> J[Movie List] G --> K[Search Bar] J --> L[Movie Card] H --> M[Movie Info] H --> N[Related Movies] style A fill:#f9f,stroke:#333,stroke-width:2px style E fill:#bbf,stroke:#333,stroke-width:2px

George Polya's Problem-Solving Approach

Step 1: Understanding the Problem

We need to create a multi-page React application with:

Step 2: Devise a Plan

  1. Set up project structure
  2. Configure React Router
  3. Create Layout component with navigation
  4. Build individual pages
    • Home page with featured movies
    • Movies page with search and list
    • Movie detail page
    • About page
  5. Implement performance optimizations
    • Lazy loading for routes
    • Memoization for expensive computations
    • Debouncing for search
  6. Add error handling and loading states
  7. Style with CSS

Step 3: Implement the Plan

Project Structure

movie-app/
├── public/
│   ├── index.html
│   └── favicon.png
├── src/
│   ├── components/
│   │   ├── Layout/
│   │   │   ├── Layout.js
│   │   │   ├── Navigation.js
│   │   │   └── Layout.css
│   │   ├── MovieCard/
│   │   │   ├── MovieCard.js
│   │   │   └── MovieCard.css
│   │   ├── SearchBar/
│   │   │   ├── SearchBar.js
│   │   │   └── SearchBar.css
│   │   └── LoadingSpinner/
│   │       ├── LoadingSpinner.js
│   │       └── LoadingSpinner.css
│   ├── pages/
│   │   ├── Home/
│   │   │   ├── Home.js
│   │   │   └── Home.css
│   │   ├── Movies/
│   │   │   ├── Movies.js
│   │   │   └── Movies.css
│   │   ├── MovieDetail/
│   │   │   ├── MovieDetail.js
│   │   │   └── MovieDetail.css
│   │   └── About/
│   │       ├── About.js
│   │       └── About.css
│   ├── hooks/
│   │   ├── useDebounce.js
│   │   └── useFetch.js
│   ├── services/
│   │   └── movieApi.js
│   ├── App.js
│   ├── App.css
│   └── index.js
├── package.json
└── README.md

Main Application Setup

File: src/App.js

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import LoadingSpinner from './components/LoadingSpinner/LoadingSpinner';
import './App.css';

// Lazy load pages for better performance
const Home = lazy(() => import('./pages/Home/Home'));
const Movies = lazy(() => import('./pages/Movies/Movies'));
const MovieDetail = lazy(() => import('./pages/MovieDetail/MovieDetail'));
const About = lazy(() => import('./pages/About/About'));

function App() {
  return (
    <Router>
      <Layout>
        <Suspense fallback={<LoadingSpinner />}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/movies" element={<Movies />} />
            <Route path="/movies/:id" element={<MovieDetail />} />
            <Route path="/about" element={<About />} />
          </Routes>
        </Suspense>
      </Layout>
    </Router>
  );
}

export default App;

Layout Component

File: src/components/Layout/Layout.js

import React from 'react';
import Navigation from './Navigation';
import './Layout.css';

const Layout = ({ children }) => {
  return (
    <div className="layout">
      <Navigation />
      <main className="main-content">
        {children}
      </main>
      <footer className="footer">
        <p>© 2024 Movie Database. All rights reserved.</p>
      </footer>
    </div>
  );
};

export default Layout;

File: src/components/Layout/Navigation.js

import React from 'react';
import { NavLink } from 'react-router-dom';

const Navigation = () => {
  return (
    <nav className="navigation">
      <div className="nav-brand">
        <h1>Movie DB</h1>
      </div>
      <ul className="nav-links">
        <li>
          <NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''}>
            Home
          </NavLink>
        </li>
        <li>
          <NavLink to="/movies" className={({ isActive }) => isActive ? 'active' : ''}>
            Movies
          </NavLink>
        </li>
        <li>
          <NavLink to="/about" className={({ isActive }) => isActive ? 'active' : ''}>
            About
          </NavLink>
        </li>
      </ul>
    </nav>
  );
};

export default Navigation;

Custom Hooks

File: src/hooks/useDebounce.js

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

File: src/hooks/useFetch.js

import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const jsonData = await response.json();
        setData(jsonData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    if (url) {
      fetchData();
    }
  }, [url]);

  return { data, loading, error };
}

export default useFetch;

Movie API Service

File: src/services/movieApi.js

const API_KEY = 'your_api_key_here';
const BASE_URL = 'https://api.themoviedb.org/3';

export const getPopularMovies = async (page = 1) => {
  const response = await fetch(
    `${BASE_URL}/movie/popular?api_key=${API_KEY}&page=${page}`
  );
  return response.json();
};

export const searchMovies = async (query, page = 1) => {
  const response = await fetch(
    `${BASE_URL}/search/movie?api_key=${API_KEY}&query=${query}&page=${page}`
  );
  return response.json();
};

export const getMovieDetails = async (id) => {
  const response = await fetch(
    `${BASE_URL}/movie/${id}?api_key=${API_KEY}`
  );
  return response.json();
};

export const getSimilarMovies = async (id) => {
  const response = await fetch(
    `${BASE_URL}/movie/${id}/similar?api_key=${API_KEY}`
  );
  return response.json();
};

Home Page Component

File: src/pages/Home/Home.js

import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import MovieCard from '../../components/MovieCard/MovieCard';
import LoadingSpinner from '../../components/LoadingSpinner/LoadingSpinner';
import { getPopularMovies } from '../../services/movieApi';
import './Home.css';

const Home = () => {
  const [movies, setMovies] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchMovies = async () => {
      try {
        const data = await getPopularMovies();
        setMovies(data.results.slice(0, 6)); // Show only 6 featured movies
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchMovies();
  }, []);

  if (loading) return <LoadingSpinner />;
  if (error) return <div className="error">Error: {error}</div>;

  return (
    <div className="home">
      <header className="hero">
        <h1>Welcome to Movie Database</h1>
        <p>Discover your next favorite movie</p>
        <Link to="/movies" className="cta-button">Browse Movies</Link>
      </header>
      
      <section className="featured">
        <h2>Featured Movies</h2>
        <div className="movie-grid">
          {movies.map(movie => (
            <MovieCard key={movie.id} movie={movie} />
          ))}
        </div>
      </section>
    </div>
  );
};

export default Home;

Movies Page with Search

File: src/pages/Movies/Movies.js

import React, { useState, useEffect, useCallback } from 'react';
import SearchBar from '../../components/SearchBar/SearchBar';
import MovieCard from '../../components/MovieCard/MovieCard';
import LoadingSpinner from '../../components/LoadingSpinner/LoadingSpinner';
import useDebounce from '../../hooks/useDebounce';
import { getPopularMovies, searchMovies } from '../../services/movieApi';
import './Movies.css';

const Movies = () => {
  const [movies, setMovies] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [searchTerm, setSearchTerm] = useState('');
  const [page, setPage] = useState(1);
  
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  const fetchMovies = useCallback(async () => {
    try {
      setLoading(true);
      const data = debouncedSearchTerm
        ? await searchMovies(debouncedSearchTerm, page)
        : await getPopularMovies(page);
      
      if (page === 1) {
        setMovies(data.results);
      } else {
        setMovies(prev => [...prev, ...data.results]);
      }
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [debouncedSearchTerm, page]);

  useEffect(() => {
    fetchMovies();
  }, [fetchMovies]);

  useEffect(() => {
    setPage(1);
  }, [debouncedSearchTerm]);

  const handleLoadMore = () => {
    setPage(prev => prev + 1);
  };

  return (
    

Movies

{error &&
Error: {error}
} <div className="movie-grid"> {movies.map(movie => ( <MovieCard key={movie.id} movie={movie} /> ))} </div> {loading && } {!loading && movies.length > 0 && ( )} {!loading && movies.length === 0 && (
No movies found
)}
); }; export default Movies;

Movie Detail Page

File: src/pages/MovieDetail/MovieDetail.js

import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import MovieCard from '../../components/MovieCard/MovieCard';
import LoadingSpinner from '../../components/LoadingSpinner/LoadingSpinner';
import { getMovieDetails, getSimilarMovies } from '../../services/movieApi';
import './MovieDetail.css';

const MovieDetail = () => {
  const { id } = useParams();
  const [movie, setMovie] = useState(null);
  const [similarMovies, setSimilarMovies] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchMovieData = async () => {
      try {
        setLoading(true);
        const [movieData, similarData] = await Promise.all([
          getMovieDetails(id),
          getSimilarMovies(id)
        ]);
        
        setMovie(movieData);
        setSimilarMovies(similarData.results.slice(0, 6));
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchMovieData();
  }, [id]);

  if (loading) return <LoadingSpinner />;
  if (error) return <div className="error">Error: {error}</div>;
  if (!movie) return <div className="error">Movie not found</div>;

  return (
    <div className="movie-detail">
      <div className="movie-header" style={{
        backgroundImage: `url(https://image.tmdb.org/t/p/original${movie.backdrop_path})`
      }}>
        <div className="movie-header-content">
          <img 
            src={`https://image.tmdb.org/t/p/w300${movie.poster_path}`}
            alt={movie.title}
            className="movie-poster"
          />
          <div className="movie-info">
            <h1>{movie.title}</h1>
            <p className="tagline">{movie.tagline}</p>
            <div className="movie-meta">
              <span>⭐ {movie.vote_average.toFixed(1)}</span>
              <span>{movie.release_date?.substring(0, 4)}</span>
              <span>{movie.runtime} min</span>
            </div>
            <p className="overview">{movie.overview}</p>
          </div>
        </div>
      </div>
      
      {similarMovies.length > 0 && (
        <section className="similar-movies">
          <h2>Similar Movies</h2>
          <div className="movie-grid">
            {similarMovies.map(movie => (
              <MovieCard key={movie.id} movie={movie} />
            ))}
          </div>
        </section>
      )}
    </div>
  );
};

export default MovieDetail;

Reusable Components

File: src/components/MovieCard/MovieCard.js

import React from 'react';
import { Link } from 'react-router-dom';
import './MovieCard.css';

const MovieCard = React.memo(({ movie }) => {
  return (
    <Link to={`/movies/${movie.id}`} className="movie-card">
      <img 
        src={`https://image.tmdb.org/t/p/w300${movie.poster_path}`}
        alt={movie.title}
        className="movie-poster"
      />
      <div className="movie-card-info">
        <h3>{movie.title}</h3>
        <p>⭐ {movie.vote_average.toFixed(1)}</p>
      </div>
    </Link>
  );
});

export default MovieCard;

File: src/components/SearchBar/SearchBar.js

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

const SearchBar = ({ value, onChange, placeholder }) => {
  return (
    <div className="search-bar">
      <input
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        className="search-input"
      />
    </div>
  );
};

export default SearchBar;

File: src/components/LoadingSpinner/LoadingSpinner.js

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

const LoadingSpinner = () => {
  return (
    <div className="loading-spinner">
      <div className="spinner"></div>
    </div>
  );
};

export default LoadingSpinner;

CSS Styles

File: src/App.css

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  background-color: #f5f5f5;
  color: #333;
}

.error {
  color: #d32f2f;
  padding: 20px;
  text-align: center;
}

File: src/components/Layout/Layout.css

.layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.main-content {
  flex: 1;
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
  width: 100%;
}

.navigation {
  background-color: #1976d2;
  color: white;
  padding: 1rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.nav-brand h1 {
  font-size: 1.5rem;
}

.nav-links {
  display: flex;
  list-style: none;
  gap: 20px;
}

.nav-links a {
  color: white;
  text-decoration: none;
  padding: 5px 10px;
  border-radius: 4px;
  transition: background-color 0.3s;
}

.nav-links a:hover {
  background-color: rgba(255, 255, 255, 0.1);
}

.nav-links a.active {
  background-color: rgba(255, 255, 255, 0.2);
}

.footer {
  background-color: #333;
  color: white;
  padding: 20px;
  text-align: center;
}

File: src/pages/Home/Home.css

.hero {
  text-align: center;
  padding: 80px 20px;
  background: linear-gradient(135deg, #1976d2, #1565c0);
  color: white;
  margin-bottom: 40px;
  border-radius: 8px;
}

.hero h1 {
  font-size: 3rem;
  margin-bottom: 20px;
}

.hero p {
  font-size: 1.5rem;
  margin-bottom: 30px;
}

.cta-button {
  display: inline-block;
  padding: 12px 24px;
  background-color: white;
  color: #1976d2;
  text-decoration: none;
  border-radius: 4px;
  font-weight: bold;
  transition: transform 0.3s;
}

.cta-button:hover {
  transform: translateY(-2px);
}

.featured h2 {
  margin-bottom: 20px;
}

.movie-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 20px;
}

File: src/components/MovieCard/MovieCard.css

.movie-card {
  background: white;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s;
  text-decoration: none;
  color: inherit;
}

.movie-card:hover {
  transform: translateY(-5px);
}

.movie-poster {
  width: 100%;
  height: 300px;
  object-fit: cover;
}

.movie-card-info {
  padding: 15px;
}

.movie-card-info h3 {
  font-size: 1rem;
  margin-bottom: 8px;
}

.movie-card-info p {
  color: #666;
}

Step 4: Review and Test

Performance Testing

// Example of testing performance with React Profiler
import { Profiler } from 'react';

function onRenderCallback(
  id, // the "id" prop of the Profiler tree that has just committed
  phase, // either "mount" (if the tree just mounted) or "update"
  actualDuration, // time spent rendering the committed update
  baseDuration, // estimated time to render the entire subtree without memoization
  startTime, // when React began rendering this update
  commitTime, // when React committed this update
  interactions // the Set of interactions belonging to this update
) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

// Wrap components to measure performance
<Profiler id="Movies" onRender={onRenderCallback}>
  <Movies />
</Profiler>

Error Boundary Implementation

// src/components/ErrorBoundary/ErrorBoundary.js
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h1>Something went wrong.</h1>
          <p>{this.state.error.message}</p>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

Application Setup Instructions

  1. Create a new React project:
    npx create-react-app movie-app
    cd movie-app
  2. Install required dependencies:
    npm install react-router-dom
  3. Create the folder structure as shown above
  4. Copy all the component files to their respective locations
  5. Get an API key from The Movie Database (TMDb) API
  6. Update the API key in the movieApi.js file
  7. Start the development server:
    npm start

Performance Optimizations Explained

Code Splitting with React.lazy()

We use React.lazy() to dynamically import components only when they're needed, reducing the initial bundle size:

const Movies = lazy(() => import('./pages/Movies/Movies'));

Memoization with React.memo()

MovieCard component is wrapped with React.memo() to prevent unnecessary re-renders when props haven't changed:

const MovieCard = React.memo(({ movie }) => {
  // Component code
});

Debouncing Search Input

The useDebounce hook delays API calls until the user stops typing, preventing excessive requests:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

Lazy Loading Images

Consider implementing lazy loading for movie posters using Intersection Observer API:

const LazyImage = ({ src, alt }) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const imgRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsLoaded(true);
        observer.disconnect();
      }
    });

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <img
      ref={imgRef}
      src={isLoaded ? src : 'placeholder.jpg'}
      alt={alt}
    />
  );
};

Real-World Applications

This pattern of multi-page React applications with performance optimizations is commonly used in:

Advanced Enhancements

Implementing Infinite Scroll

import { useInView } from 'react-intersection-observer';

const Movies = () => {
  const { ref, inView } = useInView({
    threshold: 0,
  });

  useEffect(() => {
    if (inView && !loading) {
      setPage(prev => prev + 1);
    }
  }, [inView, loading]);

  return (
    <div>
      {/* Movie grid */}
      <div ref={ref} style={{ height: '20px' }} />
    </div>
  );
};

Adding PWA Support

// In src/index.js
import * as serviceWorkerRegistration from './serviceWorkerRegistration';

serviceWorkerRegistration.register();

Implementing Dark Mode

const ThemeContext = React.createContext();

const ThemeProvider = ({ children }) => {
  const [isDark, setIsDark] = useState(false);

  const toggleTheme = () => {
    setIsDark(!isDark);
  };

  return (
    <ThemeContext.Provider value={{ isDark, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

Deployment Considerations

  1. Build the production version:
    npm run build
  2. Configure environment variables for API keys
  3. Set up client-side routing on your hosting platform
  4. Enable GZIP compression
  5. Set up proper caching headers
  6. Consider using a CDN for static assets