Preface — Day 1
This is the Day 1 step-by-step trainer guide for the CineTrack React hands-on session. Each step shows the complete file to type, explains every line, and ends with a checkpoint and common mistakes. New or changed lines in updated files are highlighted in green so you can scan directly to what's new.
What we're building — CineTrack
A movie search, watchlist, and favourites SPA using the TMDB API and Firebase Google sign-in.
| Day 1 topic | Where it shows up in the app |
|---|---|
useContext (revisit) | AuthContext — user accessible everywhere |
useMemo | Movie filter + sort — only recomputes when inputs change |
useCallback | Stable handlers passed to memoized children |
React.memo | SearchBar + GenreFilter — skip re-renders when props unchanged |
useReducer | Watchlist + Favourites state with add / remove / clear |
| Custom hooks | useDebounce, useLocalStorage, useMovies |
Pre-session Setup
Complete everything here before trainees arrive. Verify Node 18+ with node -v.
TMDB Read Access Token
- Go to themoviedb.org/settings/api and create a free account
- Copy the API Read Access Token — the long JWT starting with
eyJ… - Do not use the short API Key — it uses a different auth style and won't work with our Bearer header approach
Firebase project
- Go to console.firebase.google.com → Create project
- Authentication → Sign-in methods → Enable Google
- Project Settings → Your apps → Add web app → copy the config values
VITE_FIREBASE_AUTH_DOMAIN is your project's Firebase domain: your-project-id.firebaseapp.com. Localhost is already in Firebase's authorised domains list by default — you do not put it in the env variable.Day 1 · Chapter 1
Project Bootstrap
We build the skeleton first — routing, a navbar, stub pages, and a movie card shell — so every later chapter has something real to add to. No hooks beyond the basics yet.
Create a new Vite + React project and verify it runs.
Terminalnpm create vite@latest cinetrack -- --template react cd cinetrack npm install npm run devEvery line explained
| Command | What it does |
|---|---|
| npm create vite@latest cinetrack | Runs the Vite scaffolder at the latest version, creates a folder called cinetrack |
| -- --template react | The -- separates npm args from Vite args. --template react selects JavaScript + React (not TypeScript) |
| npm install | Downloads all dependencies listed in the generated package.json |
| npm run dev | Starts Vite's dev server with hot module replacement — saving any file updates the browser instantly |
localhost:5173. You see the default Vite + React page. ✅node -v. Vite requires Node 18+. Use nvm use 18 or upgrade if below.Install every package we'll need across both days in one go, so we never interrupt the session for installs.
Terminal — stop the dev server first (Ctrl+C), then:npm install -D tailwindcss @tailwindcss/vite npm install react-router-dom firebase zodEvery package explained
| Package | What it does |
|---|---|
| tailwindcss | Utility CSS framework — classes like bg-gray-900, flex, rounded-lg |
| @tailwindcss/vite | Tailwind v4's Vite plugin. Replaces the old PostCSS setup — no tailwind.config.js needed |
| -D flag | Dev dependency — Tailwind is a build tool, not shipped in the bundle |
| react-router-dom | Client-side routing: BrowserRouter, Routes, Route, Link, useParams |
| firebase | Firebase SDK. We use only the Auth module for Google sign-in |
| zod | Schema validation — used on Day 2 for the Profile form. Installed now to avoid interrupting Day 2 |
npm ls react-router-dom firebase zod. All three print a version number with no errors. ✅tailwind.config.js, postcss.config.js, and the npx tailwindcss init command. If you run npx tailwindcss init you get: "npm error could not determine executable to run". The v4 setup is: one Vite plugin + one CSS import.Wire Tailwind into Vite and register our custom brand colour.
Codeimport { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
@import "tailwindcss"; @theme { --color-brand: #c0392b; --color-brand-light: #e74c3c; } body { @apply bg-gray-950 text-gray-100 min-h-screen; }Every line explained
| Line | What it does |
|---|---|
| import tailwindcss from '@tailwindcss/vite' | Imports the Tailwind v4 Vite plugin |
| tailwindcss() | Registers the plugin — Vite now processes Tailwind directives in CSS files |
| @import "tailwindcss" | Replaces the old three-line directive (@tailwind base/components/utilities). One line pulls in everything |
| @theme { } | Tailwind v4's replacement for theme.extend in the config file. Values declared here become utility classes automatically |
| --color-brand: #c0392b | Registers a custom colour token. Tailwind auto-generates bg-brand, text-brand, border-brand, and hover:bg-brand from this one line |
| --color-brand-light | Lighter shade for hover states: hover:bg-brand-light |
| @apply bg-gray-950 ... | Applies three utility classes to the body. bg-gray-950 is near-black — the only place we use @apply; everywhere else we write classes directly in JSX |
npm run dev). The page background should now be very dark with white text. ✅vite.config.js. Vite reads the config only on startup. Also verify the @theme block is inside index.css (not App.css) and that index.css is imported in main.jsx.src/lib/ so every component imports from one place. If the token changes, we update one file, not ten components.Store credentials in environment variables and create two helper files the rest of the app will import.
.gitignore with .env already listed — check it before creating the file. API keys pushed to GitHub are scraped by bots within minutes.VITE_TMDB_TOKEN=eyJhbGc... # your TMDB Read Access Token (long JWT)
VITE_FIREBASE_API_KEY=AIza...
VITE_FIREBASE_AUTH_DOMAIN=your-project-id.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const provider = new GoogleAuthProvider();
const BASE = 'https://api.themoviedb.org/3';
const TOKEN = import.meta.env.VITE_TMDB_TOKEN;
export const HEADERS = {
accept: 'application/json',
Authorization: `Bearer ${TOKEN}`,
};
export const imgUrl = (path, size = 'w500') =>
path ? `https://image.tmdb.org/t/p/${size}${path}` : null;
Every line explained
| Line | What it does |
|---|---|
| VITE_TMDB_TOKEN=eyJ... | Vite only exposes env vars prefixed with VITE_ to the browser. Variables without this prefix are intentionally hidden from frontend code |
| import.meta.env.VITE_* | Vite's way of reading env vars. At build time Vite replaces these with actual values. This is not Node's process.env — that doesn't exist in the browser |
| initializeApp(firebaseConfig) | Initialises the Firebase SDK with your project credentials. Must be called once before using any Firebase service |
| getAuth(app) | Returns the Firebase Auth instance for your app. We export it so any component can import and use it |
| new GoogleAuthProvider() | Creates a Google OAuth provider. Passed to signInWithPopup later to trigger the Google sign-in popup |
| export const HEADERS | The TMDB API requires a Bearer token on every request. Exporting this object means every fetch call spreads it as headers: HEADERS instead of repeating the Authorization line |
| export const imgUrl | TMDB stores poster paths as relative strings like /abc123.jpg. This helper builds the full CDN URL. size defaults to w500 but we pass w185 for thumbnails and w1280 for backdrops |
| path ? ... : null | Some movies have no poster. Returning null lets the component skip rendering or show a fallback background colour |
import.meta.env.VITE_TMDB_TOKEN. You should see your token string. If you see undefined: wrong variable name, missing VITE_ prefix, or dev server needs restart. ✅?api_key= query params). The long JWT starting with eyJ is the Read Access Token (for Bearer headers). We use Bearer — using the wrong one gives: {"status_code":7,"status_message":"Invalid API key"}..gitignore entry ensures .env is never committed.=, no quotes around the value, prefix is exactly VITE_ in capitals, and the .env file is in the project root (same folder as package.json), not inside src/.Set up React Router with four routes. Each route renders a stub page for now.
Codeimport React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
import { Routes, Route } from 'react-router-dom';
import Navbar from './components/Navbar';
import HomePage from './pages/HomePage';
import MovieDetailPage from './pages/MovieDetailPage';
import WatchlistPage from './pages/WatchlistPage';
import ProfilePage from './pages/ProfilePage';
export default function App() {
return (
<div className="min-h-screen bg-gray-950">
<Navbar />
<main className="max-w-7xl mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/movie/:id" element={<MovieDetailPage />} />
<Route path="/watchlist" element={<WatchlistPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</main>
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| React.StrictMode | Development-only wrapper. Intentionally double-invokes effects to surface missing cleanup bugs. Does nothing in production. This is why useEffect appears to run twice in dev — expected and correct |
| BrowserRouter | Uses the browser's History API to sync the URL with React state. Clicking a Link updates the URL without a page reload. Must wrap every component that uses router hooks |
| ReactDOM.createRoot | React 18's mounting API. The older ReactDOM.render is deprecated |
| Routes | Looks at the current URL and renders the first matching Route. Only one route renders at a time |
| path="/movie/:id" | :id is a URL parameter. Both /movie/123 and /movie/456 match. The value is read with useParams() inside MovieDetailPage |
| max-w-7xl mx-auto px-4 | Centers content with a max width of 80rem and 1rem horizontal padding — creates gutters on wide screens |
react-router is the core package. react-router-dom is the browser version that includes BrowserRouter and Link. Always use react-router-dom in web projects.A working nav bar with links. No auth yet — login button is added in Chapter 2 once AuthContext exists.
Codeimport { Link } from 'react-router-dom';
export default function Navbar() {
return (
<header className="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="text-brand font-bold text-lg tracking-wide">
🎬 CineTrack
</Link>
<nav className="flex gap-4 text-sm">
<Link to="/" className="text-gray-300 hover:text-white">Home</Link>
<Link to="/watchlist" className="text-gray-300 hover:text-white">Watchlist</Link>
</nav>
</div>
</header>
);
}
Every line explained
| Line | What it does |
|---|---|
| <Link to="/"> | React Router's navigation component. Renders an <a> tag but intercepts clicks, updating the URL without a full page reload. Never use <a href="/"> for internal navigation — it causes a full reload |
| sticky top-0 z-50 | sticky keeps the header fixed while scrolling. z-50 ensures it appears above any modals or dropdowns |
| text-brand | Our custom colour from the @theme block in index.css. This confirms the Tailwind token is working |
<a> causes a full page reload, losing all React state. Always use <Link> for internal navigation.Four minimal page components so routing works and returns a visible result. We replace each one chapter by chapter.
Code — create all four filesexport default function HomePage() {
return <h1 className="text-2xl font-bold">Home</h1>;
}
export default function MovieDetailPage() {
return <h1 className="text-2xl font-bold">Movie Detail</h1>;
}
export default function WatchlistPage() {
return <h1 className="text-2xl font-bold">Watchlist</h1>;
}
export default function ProfilePage() {
return <h1 className="text-2xl font-bold">Profile</h1>;
}
Observe
/movie/123 in the URL bar — "Movie Detail" appears. Type /profile — "Profile" appears. Routing is working. ✅A reusable card that displays a movie poster, title, year, and rating.
Codeimport { Link } from 'react-router-dom';
import { imgUrl } from '../lib/tmdb';
export default function MovieCard({ movie }) {
return (
<div className="bg-gray-900 rounded-lg overflow-hidden hover:ring-2 hover:ring-brand transition-all">
<Link to={`/movie/${movie.id}`}>
<img
src={imgUrl(movie.poster_path)}
alt={movie.title}
className="w-full aspect-[2/3] object-cover bg-gray-800"
loading="lazy"
/>
</Link>
<div className="p-3">
<h3 className="font-semibold text-sm truncate">{movie.title}</h3>
<p className="text-gray-400 text-xs mt-1">
{movie.release_date?.slice(0, 4)} · ⭐ {movie.vote_average?.toFixed(1)}
</p>
{/* Watchlist buttons added in Chapter 6 */}
</div>
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| imgUrl(movie.poster_path) | Calls our helper from tmdb.js. poster_path is a relative string like /abc.jpg; the helper prepends the CDN base URL |
| aspect-[2/3] | Arbitrary Tailwind value — sets the 2:3 aspect ratio of a movie poster. All cards have the same height regardless of the image |
| object-cover | If the image doesn't match the container exactly, it scales and crops to fill rather than stretching |
| bg-gray-800 | Shown while the image loads (lazy loading). Prevents a white flash |
| loading="lazy" | Browser-native lazy loading — image only downloads when near the viewport. With 20 posters on a page, this significantly reduces initial load time |
| truncate | Adds ellipsis to long titles so they don't wrap and break the card layout |
| release_date?.slice(0, 4) | Optional chaining — safe if release_date is null. .slice(0, 4) extracts just the year from the full date string "2010-07-16" |
| vote_average?.toFixed(1) | Rounds rating to one decimal. toFixed returns a string: "8.7" not 8.7333... |
<Link> preserves native browser behaviour — right-click → "Open in new tab" works, middle-click works, the URL shows in the status bar on hover. An onClick handler loses all of that. Semantic HTML also helps screen readers understand the card is a navigation link.Day 1 · Chapter 2
Auth + Firebase
We wire up Google sign-in before building any features so trainees have a real logged-in user for the rest of the session. This revisits useContext with a real async Firebase listener, a loading gate, and an error-guarded custom hook.
user, login, and logout as props through every intermediate component that doesn't use them. Context makes auth available anywhere without props.A context that holds the Firebase user object and exposes login and logout. Any component calls useAuth() to get them.
import { createContext, useContext, useEffect, useState } from 'react';
import { onAuthStateChanged, signInWithPopup, signOut } from 'firebase/auth';
import { auth, provider } from '../lib/firebase';
const AuthContext = createContext(null);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
setLoading(false);
});
return unsubscribe;
}, []);
const login = () => signInWithPopup(auth, provider);
const logout = () => signOut(auth);
if (loading) {
return <div className="text-center py-20 text-gray-400">Loading…</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Every line explained
| Line | What it does |
|---|---|
| createContext(null) | Creates the context with a null default. If someone calls useContext(AuthContext) outside the provider they get null. Our useAuth hook catches this and throws a clear error instead of a cryptic "cannot read property of null" |
| useAuth() error guard | Wraps useContext with a check. The error message tells developers exactly what went wrong — they forgot to add AuthProvider as an ancestor |
| useState(null) for user | Starts as null because we don't know the auth state yet — Firebase needs to check its storage first |
| useState(true) for loading | We start in loading because we haven't heard from Firebase yet. We must wait before rendering anything that depends on auth |
| onAuthStateChanged(auth, callback) | Firebase registers a listener that fires: (1) immediately with the current user or null (restoring a saved session from a previous visit), and (2) again every time the user signs in or out |
| return unsubscribe | The cleanup function. onAuthStateChanged returns its own unsubscribe function. Returning it from useEffect means React calls it on unmount, preventing memory leaks and stale callbacks |
| [], empty deps | Set up the Firebase listener once on mount only. auth is stable (from our lib file) and safe to omit from deps |
| if (loading) return ... | The loading gate. Without it the app briefly flashes the logged-out state even for signed-in users while Firebase checks IndexedDB for the saved session |
| signInWithPopup(auth, provider) | Opens the Google sign-in popup. onAuthStateChanged fires automatically when sign-in completes — we don't need to handle the returned Promise ourselves |
[], the effect runs after every render, registering a new Firebase listener each time. This creates dozens of listeners and unpredictable behaviour. Always add [] to effects that should run once.useEffect(async () => {}). An async function returns a Promise, but useEffect expects the return value to be either nothing or a cleanup function. Define an async function inside the effect and call it, or use the Promise chain as shown above.uid (unique permanent ID), email, displayName, photoURL (profile picture), and emailVerified. The uid is what you'd use as a key in a database to store user-specific data.Wrap the whole app in AuthProvider. We only add AuthProvider now — WatchlistProvider is added in Chapter 6 after we've built the reducer and custom hooks it depends on.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
Every line explained
| Line | What it does |
|---|---|
| AuthProvider wraps App | Every component inside App — Navbar, all pages, all future contexts — is now a descendant of AuthProvider and can call useAuth() |
| AuthProvider inside BrowserRouter | This ordering means AuthProvider itself can use router hooks if needed. A provider can only use contexts from its own ancestors |
Show a "Sign in with Google" button when logged out, and the user's avatar + "Sign out" when logged in. The Profile link only appears when signed in.
Codeimport { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Navbar() {
const { user, login, logout } = useAuth();
return (
<header className="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="text-brand font-bold text-lg tracking-wide">
🎬 CineTrack
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link to="/" className="text-gray-300 hover:text-white">Home</Link>
<Link to="/watchlist" className="text-gray-300 hover:text-white">Watchlist</Link>
{user && (
<Link to="/profile" className="text-gray-300 hover:text-white">Profile</Link>
)}
{user ? (
<button onClick={logout} className="flex items-center gap-2 text-gray-400 hover:text-white">
<img
src={user.photoURL}
alt=""
referrerPolicy="no-referrer"
className="w-7 h-7 rounded-full"
/>
Sign out
</button>
) : (
<button
onClick={login}
className="bg-brand hover:bg-brand-light text-white px-3 py-1.5 rounded text-sm"
>
Sign in with Google
</button>
)}
</nav>
</div>
</header>
);
}
Every line explained
| Line | What it does |
|---|---|
| const { user, login, logout } = useAuth() | Reads three values from context in one destructure. Since Navbar is inside AuthProvider this always works. If called outside the provider, useAuth throws our clear error |
| {user && <Link to="/profile">} | Short-circuit evaluation. When user is null (falsy), nothing renders. When user is an object (truthy), the Link renders. React pattern for conditional rendering without if statements |
| user ? (...) : (...) | Ternary — shows the avatar+signout when logged in, the sign-in button when not. Only one renders at a time |
| user.photoURL | Firebase's user object includes the Google profile picture URL. We use it for the avatar |
| referrerPolicy="no-referrer" | Required for Google profile photos. Without this, Edge and Firefox's tracking prevention blocks the image request from lh3.googleusercontent.com. no-referrer tells the browser not to send a Referer header, bypassing the tracking check |
| rounded-full | Sets border-radius to 50%, making the square image circular |
| onClick={login} | Calls signInWithPopup from our context. No arguments needed — the Google popup handles everything |
| onClick={logout} | Calls signOut. onAuthStateChanged fires automatically after sign-out, setting user to null and re-rendering the Navbar |
closed property and the browser's COOP header restricts cross-origin window access. Auth works fine — this is a warning, not an error. Ignore it during development.createUserWithEmailAndPassword and signInWithEmailAndPassword. The AuthContext pattern is identical regardless of the auth method. Google is simpler for training because there's no registration form to build.- Not signed in — shows "Sign in with Google", no Profile link
- Click sign in — Google popup appears
- Complete sign-in — avatar and "Sign out" appear, Profile link appears
- Refresh the page — still signed in (Firebase restored the session)
- Click "Sign out" — back to sign-in button ✅
If sign-in shows a Firebase error: auth/unauthorized-domain, go to Firebase Console → Authentication → Settings → Authorised domains and verify localhost is listed.
Day 1 · Chapter 3
useMemo & useCallback
We build the movie search feature incrementally, introducing each hook only when there is a concrete problem to solve first. Never show the syntax before the problem.
Write this on the whiteboard. Ask trainees: "what's the problem here?"
function HomePage() {
const [query, setQuery] = useState('');
const [genre, setGenre] = useState(null);
// ❌ Runs on EVERY render — even if movies and genre haven't changed
const filtered = movies.filter(m => m.genre_ids.includes(genre));
const sorted = [...filtered].sort((a, b) => b.popularity - a.popularity);
// ❌ New function object created on EVERY render
const handleChange = (val) => setQuery(val);
// If a memoized child receives this prop, it sees a "new" function and re-renders
// even though the function does exactly the same thing
}
| Hook | Problem it solves | What it caches |
|---|---|---|
useMemo | Expensive computation re-runs on every render even when inputs haven't changed | A computed value |
useCallback | Function passed to memoized child creates a new reference each render, forcing a re-render | A function reference |
React.memo | Child re-renders even when its props are identical to last render | The component's rendered output |
Build the search input as a separate memoized component. The console.log inside is intentional — we use it in Step 8 to demonstrate the effect of useCallback.
import { memo } from 'react';
const SearchBar = memo(function SearchBar({ value, onChange }) {
console.log('SearchBar rendered');
return (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder="Search for a movie…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg
pl-10 pr-4 py-3 text-sm placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-brand"
/>
</div>
);
});
export default SearchBar;
Every line explained
| Line | What it does |
|---|---|
| memo(function SearchBar(...)) | Wraps the component in React.memo. Before re-rendering, React does a shallow comparison of all props to their previous values. If nothing changed, it skips the re-render and reuses the last output |
| console.log('SearchBar rendered') | Intentionally left in. In Step 8 we count these logs to see when React.memo is helping. Each log = one re-render |
| value={value} | Controlled input — the displayed value comes from the parent's state, not the DOM. React is the single source of truth |
| onChange={e => onChange(e.target.value)} | Every keystroke calls the parent's handler with just the string value (not the full event object). Cleaner interface — parent doesn't need to know about DOM events |
| pl-10 | Left padding of 2.5rem — makes room for the search icon absolutely positioned at left-3 |
| focus:ring-2 focus:ring-brand | On focus, adds a visible ring in the brand colour. Important for keyboard navigation accessibility |
===. For primitives (strings, numbers, booleans), this is value equality: "hello" === "hello" is true. For objects, arrays, and functions, this is reference equality: two different function objects with the same code are NOT equal. This is why a new function created on every render counts as a changed prop even though it does the same thing — same reason we need useCallback.Replace the stub HomePage with a real page that fetches trending movies once on mount. No search yet — just display. Build up incrementally.
Codeimport { useState, useEffect } from 'react';
import { HEADERS } from '../lib/tmdb';
import MovieCard from '../components/MovieCard';
import SearchBar from '../components/SearchBar';
const BASE = 'https://api.themoviedb.org/3';
export default function HomePage() {
const [query, setQuery] = useState('');
const [trending, setTrending] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`${BASE}/trending/movie/week`, { headers: HEADERS })
.then(r => r.json())
.then(data => {
setTrending(data.results || []);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
return (
<div>
<SearchBar value={query} onChange={setQuery} />
{loading && <p className="text-gray-400 text-center py-8">Loading…</p>}
{error && <p className="text-red-400 text-center py-8">{error}</p>}
{!loading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-4">
{trending.map(movie => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
)}
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| useState([]) for trending | Starts as an empty array. .map on an empty array renders nothing, which is correct while loading |
| useState(true) for loading | Starts as true because we begin fetching immediately. Setting false only after fetch resolves or rejects prevents an empty-grid flash |
| useState(null) for error | Null means no error. A non-null string triggers the error message in the UI |
| { headers: HEADERS } | Spreads our exported HEADERS object as the request headers, attaching the Bearer token. Without it, TMDB returns 401 Unauthorised |
| .then(r => r.json()) | The first .then gets the Response object. r.json() reads and parses the body, returning a new Promise — this is why two .then calls are needed |
| data.results || [] | TMDB wraps results in { results: [...] }. The || [] fallback prevents a crash on .map if results is null |
| [], empty deps | Run this effect once on mount only — we only want to fetch trending once when the page loads |
| grid-cols-2 sm:grid-cols-3... | Responsive grid: 2 columns on mobile, 3 on small, 4 on medium, 5 on large. Tailwind responsive prefixes apply styles above each breakpoint |
/movie/123 showing the stub page. ✅Add a second useEffect that fires the search API when query changes, and cancels the previous in-flight request on cleanup.
import { useState, useEffect } from 'react';
import { HEADERS } from '../lib/tmdb';
import MovieCard from '../components/MovieCard';
import SearchBar from '../components/SearchBar';
const BASE = 'https://api.themoviedb.org/3';
export default function HomePage() {
const [query, setQuery] = useState('');
const [movies, setMovies] = useState([]);
const [trending, setTrending] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch trending once on mount
useEffect(() => {
fetch(`${BASE}/trending/movie/week`, { headers: HEADERS })
.then(r => r.json())
.then(data => { setTrending(data.results || []); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, []);
// Fetch search results when query changes
useEffect(() => {
if (!query.trim()) { setMovies([]); return; }
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`${BASE}/search/movie?query=${encodeURIComponent(query)}&page=1`, {
signal: controller.signal,
headers: HEADERS,
})
.then(r => r.json())
.then(data => { setMovies(data.results || []); setLoading(false); })
.catch(err => {
if (err.name === 'AbortError') return;
setError(err.message);
setLoading(false);
});
return () => controller.abort();
}, [query]);
return (
<div>
<SearchBar value={query} onChange={setQuery} />
{loading && <p className="text-gray-400 text-center py-8">Loading…</p>}
{error && <p className="text-red-400 text-center py-8">{error}</p>}
{!loading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-4">
{(query.trim() ? movies : trending).map(movie => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
)}
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| if (!query.trim()) { setMovies([]); return; } | If the query is empty or only whitespace, clear search results and exit the effect early without fetching. Returns the display to trending movies when the user clears the box |
| new AbortController() | Creates a controller/signal pair. The controller has an abort() method. The signal is passed to fetch and listens for abort events |
| signal: controller.signal | Links this specific fetch request to the controller. When abort() is called, this request is cancelled at the network level |
| if (err.name === 'AbortError') return | When a fetch is aborted it throws with name 'AbortError'. We silently ignore it — we cancelled it ourselves, so it's not a real error |
| return () => controller.abort() | The cleanup function. React calls this: (1) before the next time the effect runs (when query changes), and (2) when the component unmounts. The previous in-flight request is cancelled before a new one starts |
| [query] dependency array | This effect re-runs every time query changes. Every keystroke triggers a re-render → effect cleanup → new effect → new fetch |
| encodeURIComponent(query) | Makes the query URL-safe. "Mission: Impossible" becomes "Mission%3A%20Impossible". Without this the colon and space break the URL |
| query.trim() ? movies : trending | Ternary for the data source. If there's a search query show results, otherwise show trending |
return () => controller.abort(), each keystroke fires a new fetch but never cancels the previous ones. All requests complete and the last to finish wins — which may not be the most recent query.Add genre filter and sort controls. Use useMemo so filtering and sorting only re-run when the data or filter options change.
import { useState, useEffect, useMemo } from 'react';
import { HEADERS } from '../lib/tmdb';
import MovieCard from '../components/MovieCard';
import SearchBar from '../components/SearchBar';
const BASE = 'https://api.themoviedb.org/3';
export default function HomePage() {
const [query, setQuery] = useState('');
const [movies, setMovies] = useState([]);
const [trending, setTrending] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [genreFilter, setGenreFilter] = useState(null);
const [sortBy, setSortBy] = useState('popularity');
useEffect(() => {
fetch(`${BASE}/trending/movie/week`, { headers: HEADERS })
.then(r => r.json())
.then(data => { setTrending(data.results || []); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, []);
useEffect(() => {
if (!query.trim()) { setMovies([]); return; }
const controller = new AbortController();
setLoading(true); setError(null);
fetch(`${BASE}/search/movie?query=${encodeURIComponent(query)}&page=1`, {
signal: controller.signal, headers: HEADERS,
})
.then(r => r.json())
.then(data => { setMovies(data.results || []); setLoading(false); })
.catch(err => { if (err.name === 'AbortError') return; setError(err.message); setLoading(false); });
return () => controller.abort();
}, [query]);
const displayMovies = useMemo(() => {
const source = query.trim() ? movies : trending;
const filtered = genreFilter
? source.filter(m => m.genre_ids?.includes(genreFilter))
: source;
return [...filtered].sort((a, b) => {
if (sortBy === 'popularity') return b.popularity - a.popularity;
if (sortBy === 'rating') return b.vote_average - a.vote_average;
if (sortBy === 'release_date') return new Date(b.release_date) - new Date(a.release_date);
return 0;
});
}, [movies, trending, query, genreFilter, sortBy]);
return (
<div>
<SearchBar value={query} onChange={setQuery} />
<div className="flex gap-2 my-4">
{['popularity', 'rating', 'release_date'].map(s => (
<button key={s} onClick={() => setSortBy(s)}
className={`px-3 py-1 rounded-full text-sm border transition-colors
${sortBy === s ? 'bg-brand border-brand text-white' : 'border-gray-700 text-gray-400'}`}
>{s.replace('_', ' ')}</button>
))}
</div>
{loading && <p className="text-gray-400 text-center py-8">Loading…</p>}
{error && <p className="text-red-400 text-center py-8">{error}</p>}
{!loading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{displayMovies.map(movie => <MovieCard key={movie.id} movie={movie} />)}
</div>
)}
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| useMemo(() => { ... }, [deps]) | Runs the function once and caches the return value. On subsequent renders, React checks if any dep changed using ===. If not, it returns the cached value without running the function. If any changed, it runs again |
| const source = query.trim() ? movies : trending | Picks the data source inside the memo. The same memo handles both search results and trending, switching based on whether there's a query |
| m.genre_ids?.includes(genreFilter) | Optional chaining on genre_ids because some movies have null genre arrays. Without ?. this throws "cannot read properties of null" |
| [...filtered].sort(...) | The spread creates a new array before sorting. Array.sort() mutates in place. If we sorted filtered directly we'd be mutating the memoized cached result, causing bugs on subsequent renders |
| b.popularity - a.popularity | Descending sort (highest first). Positive result means b comes before a |
| [movies, trending, query, genreFilter, sortBy] | Every value read inside useMemo must be listed. The ESLint exhaustive-deps rule enforces this. Missing a dep means the memo uses a stale cached value |
SearchBar is wrapped in React.memo. Right now we pass onChange={setQuery} which is actually stable. But we're about to add GenreFilter (also memoized) which takes a callback. If we write onChange={(id) => setGenreFilter(id)} inline, it's a new function object every render — React.memo sees a "new" prop and re-renders the child anyway. useCallback fixes this.Wrap handlers in useCallback so memoized child components don't re-render when unrelated state changes in the parent.
import { useState, useEffect, useMemo, useCallback } from 'react';
import { HEADERS } from '../lib/tmdb';
import MovieCard from '../components/MovieCard';
import SearchBar from '../components/SearchBar';
import GenreFilter from '../components/GenreFilter';
const BASE = 'https://api.themoviedb.org/3';
export default function HomePage() {
const [query, setQuery] = useState('');
const [movies, setMovies] = useState([]);
const [trending, setTrending] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [genreFilter, setGenreFilter] = useState(null);
const [sortBy, setSortBy] = useState('popularity');
useEffect(() => {
fetch(`${BASE}/trending/movie/week`, { headers: HEADERS })
.then(r => r.json())
.then(data => { setTrending(data.results || []); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, []);
useEffect(() => {
if (!query.trim()) { setMovies([]); return; }
const controller = new AbortController();
setLoading(true); setError(null);
fetch(`${BASE}/search/movie?query=${encodeURIComponent(query)}&page=1`, {
signal: controller.signal, headers: HEADERS,
})
.then(r => r.json())
.then(data => { setMovies(data.results || []); setLoading(false); })
.catch(err => { if (err.name === 'AbortError') return; setError(err.message); setLoading(false); });
return () => controller.abort();
}, [query]);
const displayMovies = useMemo(() => {
const source = query.trim() ? movies : trending;
const filtered = genreFilter
? source.filter(m => m.genre_ids?.includes(genreFilter))
: source;
return [...filtered].sort((a, b) => {
if (sortBy === 'popularity') return b.popularity - a.popularity;
if (sortBy === 'rating') return b.vote_average - a.vote_average;
if (sortBy === 'release_date') return new Date(b.release_date) - new Date(a.release_date);
return 0;
});
}, [movies, trending, query, genreFilter, sortBy]);
// Empty deps [] — setQuery and setGenreFilter from useState are guaranteed stable
const handleQueryChange = useCallback((val) => setQuery(val), []);
const handleGenreChange = useCallback((id) => setGenreFilter(id), []);
return (
<div>
<SearchBar value={query} onChange={handleQueryChange} />
<GenreFilter selected={genreFilter} onChange={handleGenreChange} />
<div className="flex gap-2 my-4">
{['popularity', 'rating', 'release_date'].map(s => (
<button key={s} onClick={() => setSortBy(s)}
className={`px-3 py-1 rounded-full text-sm border transition-colors
${sortBy === s ? 'bg-brand border-brand text-white' : 'border-gray-700 text-gray-400'}`}
>{s.replace('_', ' ')}</button>
))}
</div>
{loading && <p className="text-gray-400 text-center py-8">Loading…</p>}
{error && <p className="text-red-400 text-center py-8">{error}</p>}
{!loading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{displayMovies.map(movie => <MovieCard key={movie.id} movie={movie} />)}
</div>
)}
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| useCallback((val) => setQuery(val), []) | Returns the same function object on every render. Empty deps [] means "never recreate this function". setQuery from useState is guaranteed stable by React, so omitting it from deps is correct |
| [], empty deps | Both handlers only reference setQuery and setGenreFilter — React guarantees these never change. [] is correct: functions are created once and reused forever |
| onChange={handleQueryChange} | Now passes the stable reference. React.memo compares it to the previous render — same object, no re-render |
useMemo(() => value, deps) caches a computed value — anything: a number, array, object. useCallback(fn, deps) caches a function. They're literally equivalent: useCallback(fn, deps) is the same as useMemo(() => fn, deps). useCallback exists because "cache this function" is common enough to deserve its own named hook for clarity.A filter bar that fetches TMDB genre names and lets the user click to filter the grid. Wrapped in React.memo and uses useMemo internally to sort alphabetically.
import { useState, useEffect, useMemo, memo } from 'react';
import { HEADERS } from '../lib/tmdb';
const BASE = 'https://api.themoviedb.org/3';
const GenreFilter = memo(function GenreFilter({ selected, onChange }) {
const [genres, setGenres] = useState([]);
useEffect(() => {
fetch(`${BASE}/genre/movie/list`, { headers: HEADERS })
.then(r => r.json())
.then(data => setGenres(data.genres || []));
}, []);
const sorted = useMemo(
() => [...genres].sort((a, b) => a.name.localeCompare(b.name)),
[genres]
);
return (
<div className="flex flex-wrap gap-2 my-3">
<button onClick={() => onChange(null)}
className={`px-3 py-1 rounded-full text-xs border transition-colors
${!selected ? 'bg-brand border-brand text-white' : 'border-gray-700 text-gray-400'}`}
>All</button>
{sorted.map(g => (
<button key={g.id} onClick={() => onChange(g.id)}
className={`px-3 py-1 rounded-full text-xs border transition-colors
${selected === g.id ? 'bg-brand border-brand text-white' : 'border-gray-700 text-gray-400'}`}
>{g.name}</button>
))}
</div>
);
});
export default GenreFilter;
Every line explained
| Line | What it does |
|---|---|
| memo(function GenreFilter(...)) | Prevents re-renders when the parent updates but neither selected nor onChange changed. Since onChange is now stable via useCallback in the parent, this memoization is effective |
| useEffect(() => ..., []) | Fetches the genre list once. Genre names don't change — one fetch on mount is correct |
| [...genres].sort() | Spread before sort because sort() mutates in place. Never mutate state arrays directly |
| a.name.localeCompare(b.name) | Locale-aware string comparison. Better than a.name > b.name because it handles accented characters correctly |
| [genres] dep | Only re-sort when the genres array changes — once after the fetch, then never again |
| onChange(null) | The "All" button passes null. The parent's useMemo checks if (genreFilter) — null is falsy, so no filter is applied |
Show trainees the before and after in React DevTools Profiler. This converts abstract knowledge into something observable.
Part 1 — with useCallback (current state):
- Open React DevTools → Profiler → click Record
- Type a character in the search box
- Stop recording — SearchBar should NOT appear highlighted (skipped re-render)
- Point to the console — SearchBar's
console.logdid not fire
Part 2 — temporarily remove useCallback:
- Change
onChange={handleQueryChange}back toonChange={(val) => setQuery(val)} - Record again, type a character — SearchBar now appears in the flamegraph and the console logs
- Revert and explain: the inline function is a new object every render → React.memo sees a prop change → re-renders
Day 1 · Chapter 4
useReducer
We build the watchlist state logic as a pure reducer. At the end of this chapter the reducer exists and is testable, but nothing in the UI uses it yet — that happens in Chapter 6 when we connect it through Context.
// WITHOUT useReducer — logic scattered across many setter calls
const addToWatchlist = (movie) => setWatchlist(prev => [...prev, movie]);
const removeFromWatchlist = (id) => setWatchlist(prev => prev.filter(m => m.id !== id));
const clearWatchlist = () => setWatchlist([]);
const addToFavourites = (movie) => setFavourites(prev => [...prev, movie]);
// ...4 more
// WITH useReducer — all logic in one testable function
dispatch({ type: 'WATCHLIST_ADD', payload: movie });
dispatch({ type: 'WATCHLIST_REMOVE', payload: id });
dispatch({ type: 'WATCHLIST_CLEAR' });
function reducer(state, action) {
switch (action.type) {
case 'WATCHLIST_ADD':
return { ...state, watchlist: [...state.watchlist, action.payload] };
// all transitions in one place
}
}
| Use useState when | Use useReducer when |
|---|---|
| Single independent values | Multiple related values that change together |
| Simple next-state logic | Many action types with different transitions |
| e.g. a loading flag, a query string | e.g. a cart, a watchlist, a complex form |
A pure function — no side effects, no async, no API calls — that handles all watchlist and favourites state transitions.
Codeexport const INITIAL_STATE = {
watchlist: [],
favourites: [],
};
export function watchlistReducer(state, action) {
switch (action.type) {
case 'WATCHLIST_ADD': {
if (state.watchlist.some(m => m.id === action.payload.id)) return state;
return {
...state,
watchlist: [...state.watchlist, { ...action.payload, addedAt: Date.now() }],
};
}
case 'WATCHLIST_REMOVE':
return {
...state,
watchlist: state.watchlist.filter(m => m.id !== action.payload),
};
case 'WATCHLIST_CLEAR':
return { ...state, watchlist: [] };
case 'FAVOURITES_ADD': {
if (state.favourites.some(m => m.id === action.payload.id)) return state;
return {
...state,
favourites: [...state.favourites, { ...action.payload, addedAt: Date.now() }],
};
}
case 'FAVOURITES_REMOVE':
return {
...state,
favourites: state.favourites.filter(m => m.id !== action.payload),
};
default:
return state;
}
}
Every line explained
| Line | What it does |
|---|---|
| export const INITIAL_STATE | The starting state. Exported so WatchlistContext can pass it to useReducer, and useLocalStorage can use it as a fallback |
| function watchlistReducer(state, action) | A pure function — given the same state and action, always returns the same result. No side effects. This is what makes it testable in isolation without React |
| switch (action.type) | Routes to the correct case based on what happened. Each case handles one type of state transition |
| state.watchlist.some(m => m.id === action.payload.id) | Duplicate guard — if the movie is already in the watchlist, return the same state unchanged. Returning the same reference tells React nothing changed, preventing unnecessary re-renders |
| return state (in the guard) | Returns the exact same state object. React uses reference equality — returning the same reference means React skips re-rendering all consumers |
| { ...state, watchlist: [...] } | Immutable update pattern. Spread the old state first (to preserve favourites), then override watchlist. Never mutate state.watchlist.push() — React won't detect it |
| { ...action.payload, addedAt: Date.now() } | Adds the movie object plus a timestamp. We use addedAt in WatchlistPage to sort by most recently added |
| state.watchlist.filter(m => m.id !== action.payload) | For REMOVE, payload is just the movie id. filter returns a new array without the matching movie |
| default: return state | Unknown action types return state unchanged. Prevents crashes if an unrelated action is dispatched. In dev, add a console.warn before the return to catch typos |
| Case blocks with { } | WATCHLIST_ADD and FAVOURITES_ADD use block scope because they declare a const inside. Without the block, the const in one case would clash with the other's |
state.watchlist.push(action.payload); return state;. This mutates the existing array and returns the same reference. React sees no change — no re-render. Always return a new array or object.Functions that return action objects — prevents typos in type strings and makes dispatch calls readable.
Codeexport const actions = {
addToWatchlist: (movie) => ({ type: 'WATCHLIST_ADD', payload: movie }),
removeFromWatchlist: (id) => ({ type: 'WATCHLIST_REMOVE', payload: id }),
clearWatchlist: () => ({ type: 'WATCHLIST_CLEAR' }),
addToFavourites: (movie) => ({ type: 'FAVOURITES_ADD', payload: movie }),
removeFromFavourites:(id) => ({ type: 'FAVOURITES_REMOVE', payload: id }),
};
Every line explained
| Line | What it does |
|---|---|
| export const actions = { ... } | One exported object. Import as import { actions } from '../store/actions' and call as actions.addToWatchlist(movie) |
| (movie) => ({ type: 'WATCHLIST_ADD', payload: movie }) | Arrow function returning an object. The outer () around {} are required — without them JS interprets {} as a function body, not an object literal |
| payload: movie vs payload: id | addToWatchlist sends the full movie object (reducer needs all fields). removeFromWatchlist sends just the id (reducer only needs it to filter) |
dispatch({ type: 'WATCHLIST_ADD', payload: movie }) vs dispatch(actions.addToWatchlist(movie)). With raw strings, a typo like 'WATCHLIST_ADDD' silently hits the default case — nothing happens and there's no error. With action creators, the call is autocompleted and the error is caught immediately.Show trainees that a reducer is a plain JavaScript function — no React needed to test it.
const s0 = { watchlist: [], favourites: [] };
// Add a movie
const s1 = watchlistReducer(s0, { type: 'WATCHLIST_ADD', payload: { id: 1, title: 'Inception' } });
console.log(s1.watchlist.length); // → 1
// Add same movie again (duplicate guard)
const s2 = watchlistReducer(s1, { type: 'WATCHLIST_ADD', payload: { id: 1, title: 'Inception' } });
console.log(s2.watchlist.length); // → still 1 (idempotent)
// Remove
const s3 = watchlistReducer(s2, { type: 'WATCHLIST_REMOVE', payload: 1 });
console.log(s3.watchlist.length); // → 0
// Confirm original state was never mutated
console.log(s0.watchlist.length); // → 0 ✓
console.log(s1.watchlist.length); // → 1 ✓
useReducer(watchlistReducer, INITIAL_STATE) inside a context provider, then exposes dispatch and state to components via context.Day 1 · Chapter 5
Custom Hooks
We extract three reusable pieces of logic into custom hooks. After this chapter we have all the building blocks needed to wire everything together in Chapter 6.
Three requirements for a custom hook:
- Name starts with
use— required by the ESLint plugin. Without it, rules-of-hooks lint checks don't apply to the function - Calls at least one React hook inside it
- Is a regular JavaScript function — no special registration or class
useMovies('inception') each get their own independent movies, loading, and error state. The hook is a recipe — each caller gets their own kitchen. If you want shared state between components, you need Context.| Hook we're building | Problem it solves | Used by |
|---|---|---|
useDebounce | API fires on every keystroke — 10 requests per second | useMovies |
useLocalStorage | Watchlist disappears on page refresh | WatchlistContext |
useMovies | Fetch logic repeated in every component that needs movies | HomePage |
User types: i n c e p (pause 400ms) Timer starts: ✓ Timer resets: ✓ ✓ ✓ ✓ API call fires: ✓ (once, for "incep")Goal
A hook that returns a delayed copy of a value — only updates after the user stops changing it for the specified delay.
Codeimport { useState, useEffect } from 'react';
export function useDebounce(value, delay = 400) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
Every line explained
| Line | What it does |
|---|---|
| useState(value) | Initialises with the current value so there's no delay on first render. Debounced starts in sync with the real value |
| setTimeout(() => setDebounced(value), delay) | Schedules an update after delay ms. If nothing changes before the timer fires, debounced gets updated |
| return () => clearTimeout(id) | The cleanup. When value changes again before the timer fires, React calls this cleanup before running the effect again. clearTimeout cancels the pending timer. This is the mechanism — every new value cancels the previous timer and starts fresh |
| [value, delay] deps | Re-run whenever value or delay changes. Since value changes on every keystroke, this effect runs and resets the timer on every keystroke |
| return debounced | The caller uses this in their dependency array instead of the raw value, so effects only fire when the user pauses |
return () => clearTimeout(id), every keystroke adds a new timer. After typing "inception" the user has 9 pending timers, all of which fire after 400ms — 9 state updates in rapid succession. The cleanup cancels each previous timer before starting the next.A drop-in replacement for useState that reads from and writes to localStorage automatically.
import { useState, useCallback } from 'react';
export function useLocalStorage(key, initialValue) {
const [stored, setStored] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value) => {
try {
const toStore = value instanceof Function ? value(stored) : value;
setStored(toStore);
localStorage.setItem(key, JSON.stringify(toStore));
} catch (err) {
console.warn(`useLocalStorage: could not save "${key}"`, err);
}
},
[key, stored]
);
return [stored, setValue];
}
Every line explained
| Line | What it does |
|---|---|
| useState(() => { ... }) | Lazy initialiser — the function runs once on mount to read from localStorage. Without the function wrapper, localStorage.getItem(key) would run on every render (wasteful) |
| localStorage.getItem(key) | Reads the stored string. Returns null if nothing is stored yet |
| item ? JSON.parse(item) : initialValue | If stored data exists, parse it back to a JS value. If not, use the provided initial value |
| try/catch around localStorage | localStorage throws in private browsing mode (SecurityError) or when storage is full (QuotaExceededError). The try/catch gracefully falls back to initialValue |
| value instanceof Function ? value(stored) : value | Supports the functional updater pattern. Callers can write setValue(prev => [...prev, item]) — same pattern as setState. Without this, functional updates would fail because value would be a function, not the new state |
| localStorage.setItem(key, JSON.stringify(toStore)) | Persists as a JSON string. localStorage only stores strings — we must serialise JS values |
| useCallback([key, stored]) | WatchlistContext passes setValue to a useEffect dep array. Without useCallback it's a new function every render, causing infinite re-renders |
| return [stored, setValue] | Same interface as useState. Callers destructure it the same way: const [value, setValue] = useLocalStorage('key', default) |
Extract all TMDB fetch logic from HomePage into a reusable hook. This hook calls useDebounce internally so the search effect only fires after the user pauses.
import { useState, useEffect } from 'react';
import { HEADERS } from '../lib/tmdb';
import { useDebounce } from './useDebounce';
const BASE = 'https://api.themoviedb.org/3';
export function useMovies(query) {
const [movies, setMovies] = useState([]);
const [trending, setTrending] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
fetch(`${BASE}/trending/movie/week`, { headers: HEADERS })
.then(r => r.json())
.then(d => setTrending(d.results || []))
.catch(e => setError(e.message));
}, []);
useEffect(() => {
if (!debouncedQuery.trim()) { setMovies([]); return; }
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`${BASE}/search/movie?query=${encodeURIComponent(debouncedQuery)}&page=1`, {
signal: controller.signal,
headers: HEADERS,
})
.then(r => r.json())
.then(data => { setMovies(data.results || []); setLoading(false); })
.catch(err => {
if (err.name === 'AbortError') return;
setError(err.message);
setLoading(false);
});
return () => controller.abort();
}, [debouncedQuery]);
return { movies, trending, loading, error };
}
Every line explained
| Line | What it does |
|---|---|
| const debouncedQuery = useDebounce(query, 400) | One line replaces all debounce timer logic we'd otherwise write here. This is the power of custom hooks composing — each handles one concern and they stack cleanly |
| [debouncedQuery] in search effect deps | The effect now depends on the debounced value, not the raw query. Only fires 400ms after the user stops typing, not on every keystroke |
| return { movies, trending, loading, error } | Returns four values as a named object. The caller destructures what they need: const { movies, loading } = useMovies(query). Named properties are clearer than positional returns for more than two values |
Replace all the fetch state and effects in HomePage with a single hook call. Show trainees the before and after.
Codeimport { useState, useMemo, useCallback } from 'react';
import { useMovies } from '../hooks/useMovies';
import MovieCard from '../components/MovieCard';
import SearchBar from '../components/SearchBar';
import GenreFilter from '../components/GenreFilter';
export default function HomePage() {
const [query, setQuery] = useState('');
const [genreFilter, setGenreFilter] = useState(null);
const [sortBy, setSortBy] = useState('popularity');
const { movies, trending, loading, error } = useMovies(query);
const displayMovies = useMemo(() => {
const source = query.trim() ? movies : trending;
const filtered = genreFilter
? source.filter(m => m.genre_ids?.includes(genreFilter))
: source;
return [...filtered].sort((a, b) => {
if (sortBy === 'popularity') return b.popularity - a.popularity;
if (sortBy === 'rating') return b.vote_average - a.vote_average;
if (sortBy === 'release_date') return new Date(b.release_date) - new Date(a.release_date);
return 0;
});
}, [movies, trending, query, genreFilter, sortBy]);
const handleQueryChange = useCallback((val) => setQuery(val), []);
const handleGenreChange = useCallback((id) => setGenreFilter(id), []);
return (
<div>
<SearchBar value={query} onChange={handleQueryChange} />
<GenreFilter selected={genreFilter} onChange={handleGenreChange} />
<div className="flex gap-2 my-4">
{['popularity', 'rating', 'release_date'].map(s => (
<button key={s} onClick={() => setSortBy(s)}
className={`px-3 py-1 rounded-full text-sm border transition-colors
${sortBy === s ? 'bg-brand border-brand text-white' : 'border-gray-700 text-gray-400'}`}
>{s.replace('_', ' ')}</button>
))}
</div>
{loading && <p className="text-gray-400 text-center py-8">Loading…</p>}
{error && <p className="text-red-400 text-center py-8">{error}</p>}
{!loading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{displayMovies.map(movie => <MovieCard key={movie.id} movie={movie} />)}
</div>
)}
{!loading && displayMovies.length === 0 && query && (
<p className="text-gray-500 text-center py-16">No results for "{query}"</p>
)}
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| const { movies, trending, loading, error } = useMovies(query) | One line replaces approximately 30 lines of useState + useEffect. The hook manages all fetch state internally. The component is now only responsible for display logic |
| Removed: two useEffects, four useState calls | Those now live inside useMovies. The component is leaner and its purpose is clearer |
| displayMovies.length === 0 && query | Shows "No results" only when a search was made (query exists) and returned nothing. Without the && query guard it would also show "No results" briefly while trending loads |
useMovies(query) and get its own independent state. If two components both call it, they each fetch independently. If you want them to share state, call useMovies once in a parent and pass results as props, or wrap it in a Context provider.Day 1 · Chapter 6
WatchlistContext
We now have all the pieces: the reducer (Ch4), useLocalStorage (Ch5), and AuthContext (Ch2). This chapter wires them together. This is also where MovieCard gets its watchlist buttons and WatchlistPage gets real data.
import { createContext, useContext, useReducer, useEffect } from 'react';
import { watchlistReducer, INITIAL_STATE } from '../store/watchlistReducer';
import { actions } from '../store/actions';
import { useLocalStorage } from '../hooks/useLocalStorage';
const WatchlistContext = createContext(null);
export function useWatchlist() {
const ctx = useContext(WatchlistContext);
if (!ctx) throw new Error('useWatchlist must be used inside <WatchlistProvider>');
return ctx;
}
export function WatchlistProvider({ children }) {
const [persisted, setPersisted] = useLocalStorage('cinetrack-state', INITIAL_STATE);
const [state, dispatch] = useReducer(watchlistReducer, persisted);
useEffect(() => {
setPersisted(state);
}, [state, setPersisted]);
const isInWatchlist = (id) => state.watchlist.some(m => m.id === id);
const isInFavourites = (id) => state.favourites.some(m => m.id === id);
const value = {
watchlist: state.watchlist,
favourites: state.favourites,
isInWatchlist,
isInFavourites,
addToWatchlist: (movie) => dispatch(actions.addToWatchlist(movie)),
removeFromWatchlist: (id) => dispatch(actions.removeFromWatchlist(id)),
clearWatchlist: () => dispatch(actions.clearWatchlist()),
addToFavourites: (movie) => dispatch(actions.addToFavourites(movie)),
removeFromFavourites:(id) => dispatch(actions.removeFromFavourites(id)),
};
return (
<WatchlistContext.Provider value={value}>
{children}
</WatchlistContext.Provider>
);
}
Every line explained
| Line | What it does |
|---|---|
| useLocalStorage('cinetrack-state', INITIAL_STATE) | Step 1. On mount, reads any previously saved state from localStorage under the key 'cinetrack-state'. If nothing is saved, returns INITIAL_STATE (empty lists) |
| useReducer(watchlistReducer, persisted) | Step 2. Initialises the reducer with the persisted state instead of a hardcoded empty default. On page reload, the reducer starts with the saved watchlist from the previous session |
| useEffect(() => setPersisted(state), [state, setPersisted]) | Step 3. Every time state changes (after any dispatch), write the new state to localStorage. Keeps localStorage in sync automatically |
| isInWatchlist = (id) => state.watchlist.some(...) | Helper returning a boolean — is this movie in the watchlist? MovieCard calls this to decide the button text and colour |
| addToWatchlist: (movie) => dispatch(actions.addToWatchlist(movie)) | Wraps dispatch + action creator into one clean function. Components call addToWatchlist(movie) without knowing about dispatch or action types. This is the context interface pattern: expose a clean API, hide the implementation |
setPersisted is not stable. Our useLocalStorage wraps setValue in useCallback — if that's missing, every render creates a new setPersisted reference, the effect sees a new dep, runs again, triggers a render, and so on.useAuth() if it ever needs the current user (e.g. to sync to a server). If the order were reversed, that wouldn't work. Outer providers should be the ones inner providers may depend on.import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { WatchlistProvider } from './context/WatchlistContext';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<WatchlistProvider>
<App />
</WatchlistProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
Every line explained
| Line | What it does |
|---|---|
| WatchlistProvider inside AuthProvider | WatchlistProvider is a descendant of AuthProvider, so WatchlistContext can call useAuth() if needed. Every component inside App can now call both useAuth() and useWatchlist() |
Add watchlist and favourites toggle buttons. WatchlistContext now exists so MovieCard can call useWatchlist().
import { Link } from 'react-router-dom';
import { imgUrl } from '../lib/tmdb';
import { useWatchlist } from '../context/WatchlistContext';
export default function MovieCard({ movie }) {
const {
isInWatchlist, addToWatchlist, removeFromWatchlist,
isInFavourites, addToFavourites, removeFromFavourites,
} = useWatchlist();
const inWatchlist = isInWatchlist(movie.id);
const inFavourites = isInFavourites(movie.id);
return (
<div className="bg-gray-900 rounded-lg overflow-hidden hover:ring-2 hover:ring-brand transition-all">
<Link to={`/movie/${movie.id}`}>
<img
src={imgUrl(movie.poster_path)}
alt={movie.title}
className="w-full aspect-[2/3] object-cover bg-gray-800"
loading="lazy"
/>
</Link>
<div className="p-3">
<h3 className="font-semibold text-sm truncate">{movie.title}</h3>
<p className="text-gray-400 text-xs mt-1">
{movie.release_date?.slice(0, 4)} · ⭐ {movie.vote_average?.toFixed(1)}
</p>
<div className="mt-2 flex gap-1">
<button
onClick={() => inWatchlist ? removeFromWatchlist(movie.id) : addToWatchlist(movie)}
className={`flex-1 text-xs py-1 rounded transition-colors
${inWatchlist ? 'bg-brand text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
>
{inWatchlist ? '✓ Watchlist' : '+ Watchlist'}
</button>
<button
onClick={() => inFavourites ? removeFromFavourites(movie.id) : addToFavourites(movie)}
className={`px-2 py-1 rounded text-sm transition-colors
${inFavourites ? 'text-yellow-400' : 'text-gray-600 hover:text-gray-400'}`}
>♥</button>
</div>
</div>
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| useWatchlist() | Reads the WatchlistContext. Every MovieCard instance reads the same shared context — they all see the same watchlist state |
| const inWatchlist = isInWatchlist(movie.id) | Calls the context helper with this specific movie's id. Returns true/false — drives the button text and colour |
| inWatchlist ? removeFromWatchlist(movie.id) : addToWatchlist(movie) | Toggle pattern. One button does both add and remove. Pass movie.id to remove (reducer only needs the id to filter). Pass the full movie to add (reducer stores the full object) |
| flex-1 | Makes the watchlist button fill remaining space after the fixed-width heart button |
import { useMemo } from 'react';
import { useWatchlist } from '../context/WatchlistContext';
import MovieCard from '../components/MovieCard';
export default function WatchlistPage() {
const { watchlist, favourites, clearWatchlist } = useWatchlist();
const sorted = useMemo(
() => [...watchlist].sort((a, b) => b.addedAt - a.addedAt),
[watchlist]
);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">
Watchlist <span className="text-gray-500 text-lg">({watchlist.length})</span>
</h1>
{watchlist.length > 0 && (
<button onClick={clearWatchlist} className="text-sm text-red-400 hover:text-red-300">
Clear all
</button>
)}
</div>
{sorted.length === 0 ? (
<p className="text-gray-500 text-center py-16">Your watchlist is empty.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{sorted.map(movie => <MovieCard key={movie.id} movie={movie} />)}
</div>
)}
{favourites.length > 0 && (
<div className="mt-12">
<h2 className="text-xl font-bold mb-4">
Favourites <span className="text-gray-500">({favourites.length})</span>
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{favourites.map(movie => <MovieCard key={movie.id} movie={movie} />)}
</div>
</div>
)}
</div>
);
}
Every line explained
| Line | What it does |
|---|---|
| b.addedAt - a.addedAt | Descending sort by timestamp — most recently added appears first. addedAt is Date.now(), a millisecond timestamp. Larger number = more recent |
| useMemo([watchlist]) | Only re-sorts when the watchlist changes. WatchlistPage can re-render for other reasons (e.g. context changes) — without useMemo it would re-sort on every render |
| watchlist.length > 0 && ... | Only shows "Clear all" when there's something to clear |
| favourites.length > 0 && ... | The Favourites section only renders if there are any favourites — avoids an empty heading |
- Sign in with Google — avatar appears in Navbar
- Browse home page — trending movies in a grid
- Click a genre — grid narrows
- Click a sort button — movies reorder
- Type in the search box — pause 400ms — results appear (debounced, check Network tab)
- Click "+ Watchlist" on a movie — button turns red
- Click ♥ on another movie — heart turns yellow
- Navigate to /watchlist — both lists appear, sorted by most recently added
- Refresh the page — watchlist and favourites are still there ← localStorage
- Click "Clear all" — watchlist empties
- Sign out — avatar gone, Profile link gone
If all of these work, every Day 1 concept has a working, observable outcome. ✅
Reference