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:
- Multiple pages with React Router
- Performance optimizations using React.memo, useMemo, and lazy loading
- Data fetching with error handling
- Search functionality with debouncing
- Responsive design
Project Architecture
George Polya's Problem-Solving Approach
Step 1: Understanding the Problem
We need to create a multi-page React application with:
- Multiple routes (Home, Movies, Movie Details, About)
- Performance optimizations (code splitting, memoization)
- Data fetching from an API
- Search functionality with debouncing
- Responsive layout
Step 2: Devise a Plan
- Set up project structure
- Configure React Router
- Create Layout component with navigation
- Build individual pages
- Home page with featured movies
- Movies page with search and list
- Movie detail page
- About page
- Implement performance optimizations
- Lazy loading for routes
- Memoization for expensive computations
- Debouncing for search
- Add error handling and loading states
- 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
- Create a new React project:
npx create-react-app movie-app cd movie-app - Install required dependencies:
npm install react-router-dom - Create the folder structure as shown above
- Copy all the component files to their respective locations
- Get an API key from The Movie Database (TMDb) API
- Update the API key in the movieApi.js file
- 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:
- E-commerce websites with product catalogs
- News platforms with article listings
- Social media dashboards
- Content management systems
- Portfolio websites
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
- Build the production version:
npm run build - Configure environment variables for API keys
- Set up client-side routing on your hosting platform
- Enable GZIP compression
- Set up proper caching headers
- Consider using a CDN for static assets