Introduction

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.

How to use this guideAlways run the app after each step before moving on. Read one step ahead of where the class is. If something breaks, the mistake section at the end of each step tells you what to look for.
Code conventionEvery step shows the complete file. On the first time a file is shown the entire file is new. On updates, unchanged lines appear in normal colour and new or changed lines are highlighted with a green left bar. You never need to type a file from scratch more than once.

What we're building — CineTrack

A movie search, watchlist, and favourites SPA using the TMDB API and Firebase Google sign-in.

Day 1 topicWhere it shows up in the app
useContext (revisit)AuthContext — user accessible everywhere
useMemoMovie filter + sort — only recomputes when inputs change
useCallbackStable handlers passed to memoized children
React.memoSearchBar + GenreFilter — skip re-renders when props unchanged
useReducerWatchlist + Favourites state with add / remove / clear
Custom hooksuseDebounce, useLocalStorage, useMovies

Pre-session Setup

Complete everything here before trainees arrive. Verify Node 18+ with node -v.

TMDB Read Access Token

  1. Go to themoviedb.org/settings/api and create a free account
  2. Copy the API Read Access Token — the long JWT starting with eyJ…
  3. Do not use the short API Key — it uses a different auth style and won't work with our Bearer header approach

Firebase project

  1. Go to console.firebase.google.com → Create project
  2. Authentication → Sign-in methods → Enable Google
  3. Project Settings → Your apps → Add web app → copy the config values
Auth domain is not localhostVITE_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.

STEP 1Scaffold the project
Goal

Create a new Vite + React project and verify it runs.

Terminal
npm create vite@latest cinetrack -- --template react
cd cinetrack
npm install
npm run dev
Every line explained
CommandWhat it does
npm create vite@latest cinetrackRuns the Vite scaffolder at the latest version, creates a folder called cinetrack
-- --template reactThe -- separates npm args from Vite args. --template react selects JavaScript + React (not TypeScript)
npm installDownloads all dependencies listed in the generated package.json
npm run devStarts Vite's dev server with hot module replacement — saving any file updates the browser instantly
Observe
CheckpointBrowser opens at localhost:5173. You see the default Vite + React page. ✅
Common mistakes
Wrong Node versionRun node -v. Vite requires Node 18+. Use nvm use 18 or upgrade if below.
STEP 2Install all dependencies
Goal

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 zod
Every package explained
PackageWhat it does
tailwindcssUtility CSS framework — classes like bg-gray-900, flex, rounded-lg
@tailwindcss/viteTailwind v4's Vite plugin. Replaces the old PostCSS setup — no tailwind.config.js needed
-D flagDev dependency — Tailwind is a build tool, not shipped in the bundle
react-router-domClient-side routing: BrowserRouter, Routes, Route, Link, useParams
firebaseFirebase SDK. We use only the Auth module for Google sign-in
zodSchema validation — used on Day 2 for the Profile form. Installed now to avoid interrupting Day 2
Observe
CheckpointRun npm ls react-router-dom firebase zod. All three print a version number with no errors. ✅
STEP 3Configure Tailwind v4
Why this way
Tailwind v4 is completely different from v3Tailwind v4 dropped 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.
Goal

Wire Tailwind into Vite and register our custom brand colour.

Code
vite.config.js — full file
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
  ],
});
src/index.css — full file (replace everything)
@import "tailwindcss";

@theme {
  --color-brand: #c0392b;
  --color-brand-light: #e74c3c;
}

body {
  @apply bg-gray-950 text-gray-100 min-h-screen;
}
Every line explained
LineWhat 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: #c0392bRegisters a custom colour token. Tailwind auto-generates bg-brand, text-brand, border-brand, and hover:bg-brand from this one line
--color-brand-lightLighter 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
Observe
CheckpointRestart the dev server (npm run dev). The page background should now be very dark with white text. ✅
Common mistakes
bg-brand not workingThe dev server must be restarted after editing 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.
STEP 4Create .env and lib helper files
Why this way
Why separate lib files?We put Firebase and TMDB setup in src/lib/ so every component imports from one place. If the token changes, we update one file, not ten components.
Goal

Store credentials in environment variables and create two helper files the rest of the app will import.

Add .env to .gitignore immediatelyVite's scaffold creates .gitignore with .env already listed — check it before creating the file. API keys pushed to GitHub are scraped by bots within minutes.
Code
.env — create in project root
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
src/lib/firebase.js — full file
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();
src/lib/tmdb.js — full file
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
LineWhat 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 HEADERSThe 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 imgUrlTMDB 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 ? ... : nullSome movies have no poster. Returning null lets the component skip rendering or show a fallback background colour
Observe
CheckpointOpen the browser console and type: 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. ✅
Common mistakes
API Key vs Read Access TokenTMDB has two credentials on the settings page. The short alphanumeric string is the API Key (for ?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"}.
Q & A
Why can't we just hardcode the API key directly in the code?
If you commit hardcoded secrets to git, they're public. Bots scan GitHub for API keys and will use your TMDB token, potentially burning through your rate limit. The .gitignore entry ensures .env is never committed.
Token still undefined after restarting?
Check: no spaces around =, 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/.
STEP 5main.jsx + App.jsx — routing setup
Goal

Set up React Router with four routes. Each route renders a stub page for now.

Code
src/main.jsx — full file
import 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>
);
src/App.jsx — full file
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
LineWhat it does
React.StrictModeDevelopment-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
BrowserRouterUses 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.createRootReact 18's mounting API. The older ReactDOM.render is deprecated
RoutesLooks 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-4Centers content with a max width of 80rem and 1rem horizontal padding — creates gutters on wide screens
Common mistakes
Importing from 'react-router' instead of 'react-router-dom'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.
Q & A
Why does useEffect run twice in development?
React 18 StrictMode intentionally mounts → unmounts → remounts every component. This runs effects twice to catch bugs from missing cleanup. In production it runs once. If your app behaves differently on the second mount, your cleanup function is incomplete.
STEP 6Navbar — stub
Goal

A working nav bar with links. No auth yet — login button is added in Chapter 2 once AuthContext exists.

Code
src/components/Navbar.jsx — full file
import { 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
LineWhat 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-50sticky keeps the header fixed while scrolling. z-50 ensures it appears above any modals or dropdowns
text-brandOur custom colour from the @theme block in index.css. This confirms the Tailwind token is working
Common mistakes
Using <a href> instead of <Link to>Common from trainees new to React Router. A regular <a> causes a full page reload, losing all React state. Always use <Link> for internal navigation.
STEP 7Stub pages
Goal

Four minimal page components so routing works and returns a visible result. We replace each one chapter by chapter.

Code — create all four files
src/pages/HomePage.jsx — full file
export default function HomePage() {
  return <h1 className="text-2xl font-bold">Home</h1>;
}
src/pages/MovieDetailPage.jsx — full file
export default function MovieDetailPage() {
  return <h1 className="text-2xl font-bold">Movie Detail</h1>;
}
src/pages/WatchlistPage.jsx — full file
export default function WatchlistPage() {
  return <h1 className="text-2xl font-bold">Watchlist</h1>;
}
src/pages/ProfilePage.jsx — full file
export default function ProfilePage() {
  return <h1 className="text-2xl font-bold">Profile</h1>;
}
Observe
CheckpointClick Home and Watchlist links — each shows the stub heading without a page reload. Type /movie/123 in the URL bar — "Movie Detail" appears. Type /profile — "Profile" appears. Routing is working. ✅
STEP 8MovieCard — stub
Why build this now with no data
Incremental buildingHaving MovieCard ready means every later chapter can immediately display results. The watchlist buttons are deliberately absent — they require WatchlistContext which doesn't exist until Chapter 6. This is intentional, not incomplete.
Goal

A reusable card that displays a movie poster, title, year, and rating.

Code
src/components/MovieCard.jsx — full file
import { 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
LineWhat 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-coverIf the image doesn't match the container exactly, it scales and crops to fill rather than stretching
bg-gray-800Shown 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
truncateAdds 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...
Q & A
Why use Link around the image instead of onClick on the card?
Using <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.

STEP 1Create AuthContext
Why Context for auth
Avoiding prop drillingThe current user is needed in the Navbar, ProfilePage, ReviewForm, and anywhere user-specific content appears. Without Context we'd pass user, login, and logout as props through every intermediate component that doesn't use them. Context makes auth available anywhere without props.
Goal

A context that holds the Firebase user object and exposes login and logout. Any component calls useAuth() to get them.

Code
src/context/AuthContext.jsx — full file
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
LineWhat 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 guardWraps useContext with a check. The error message tells developers exactly what went wrong — they forgot to add AuthProvider as an ancestor
useState(null) for userStarts as null because we don't know the auth state yet — Firebase needs to check its storage first
useState(true) for loadingWe 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 unsubscribeThe 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 depsSet 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
Common mistakes
Forgetting the empty dependency arrayWithout [], 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.
Making the useEffect asyncSome trainees try 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.
Q & A
Why does onAuthStateChanged fire immediately with null if no one is logged in?
Firebase checks its local IndexedDB storage for a saved session. This is asynchronous. The immediate null fire is Firebase telling us "I've checked and there's no saved session." Without this, we'd be stuck in the loading state forever when no one is signed in.
What does the Firebase user object contain?
The most useful fields: 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.
STEP 2Wire AuthProvider into main.jsx
Goal

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.

Code
src/main.jsx — full file
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
LineWhat it does
AuthProvider wraps AppEvery component inside App — Navbar, all pages, all future contexts — is now a descendant of AuthProvider and can call useAuth()
AuthProvider inside BrowserRouterThis ordering means AuthProvider itself can use router hooks if needed. A provider can only use contexts from its own ancestors
Observe
CheckpointThe app still loads. You'll briefly see "Loading…" while Firebase checks the session, then the page renders. The loading gate is working. ✅
STEP 3Navbar — add login UI
Goal

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.

Code
src/components/Navbar.jsx — full file
import { 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
LineWhat 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.photoURLFirebase'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-fullSets 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
Common mistakes
COOP warning in the consoleYou may see: "Cross-Origin-Opener-Policy policy would block the window.closed call". Firebase checks the popup window's 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.
Q & A
Can we use email/password auth instead of Google?
Yes — enable it in Firebase Console and use 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.
STEP 4Test sign-in — chapter 2 checkpoint
Test all four states before moving on
  1. Not signed in — shows "Sign in with Google", no Profile link
  2. Click sign in — Google popup appears
  3. Complete sign-in — avatar and "Sign out" appear, Profile link appears
  4. Refresh the page — still signed in (Firebase restored the session)
  5. 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.

STEP 1Concept — the re-render problem
Before touching the keyboard

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
}
HookProblem it solvesWhat it caches
useMemoExpensive computation re-runs on every render even when inputs haven't changedA computed value
useCallbackFunction passed to memoized child creates a new reference each render, forcing a re-renderA function reference
React.memoChild re-renders even when its props are identical to last renderThe component's rendered output
STEP 2SearchBar — with React.memo
Goal

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.

Code
src/components/SearchBar.jsx — full file
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
LineWhat 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-10Left padding of 2.5rem — makes room for the search icon absolutely positioned at left-3
focus:ring-2 focus:ring-brandOn focus, adds a visible ring in the brand colour. Important for keyboard navigation accessibility
Q & A
What does "shallow compare" mean in React.memo?
React compares each prop using ===. 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.
STEP 3HomePage — fetch trending movies on mount
Goal

Replace the stub HomePage with a real page that fetches trending movies once on mount. No search yet — just display. Build up incrementally.

Code
src/pages/HomePage.jsx — full file
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 [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
LineWhat it does
useState([]) for trendingStarts as an empty array. .map on an empty array renders nothing, which is correct while loading
useState(true) for loadingStarts as true because we begin fetching immediately. Setting false only after fetch resolves or rejects prevents an empty-grid flash
useState(null) for errorNull 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 depsRun 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
Observe
CheckpointTrending movies appear in a responsive grid. The search box renders but typing does nothing yet. Click a card — navigates to /movie/123 showing the stub page. ✅
Common mistakes
"Invalid API key" text on screenIf the TMDB error JSON appears as raw text, check: (1) HEADERS is imported from tmdb.js, (2) the .env token is the Read Access Token (long JWT), (3) the dev server was restarted after creating .env.
STEP 4Add search with AbortController
Why AbortController here
The race condition problemWithout cancellation: user types "inc" → fetch starts. Before it resolves, user types "incp" → second fetch starts. If the first fetch resolves after the second, it overwrites the results and the UI shows results for "inc" not "incp". AbortController cancels the previous request before starting a new one.
Goal

Add a second useEffect that fires the search API when query changes, and cancels the previous in-flight request on cleanup.

Code
src/pages/HomePage.jsx — full file
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
LineWhat 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.signalLinks this specific fetch request to the controller. When abort() is called, this request is cancelled at the network level
if (err.name === 'AbortError') returnWhen 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 arrayThis 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 : trendingTernary for the data source. If there's a search query show results, otherwise show trending
Observe
CheckpointType in the search box — results update on every keystroke. Open Network tab and type quickly — earlier requests get crossed out (cancelled). Clear the box — trending returns. ✅
Common mistakes
Forgetting the cleanup returnWithout 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.
STEP 5useMemo — derive the display list
Why useMemo here
The problem we're solvingWe're adding genre filtering and sorting. Without useMemo, every time the user types (updating query state), the filter and sort run again even if genre and sort order haven't changed. useMemo caches the result and only recomputes when its inputs actually change.
Goal

Add genre filter and sort controls. Use useMemo so filtering and sorting only re-run when the data or filter options change.

Code
src/pages/HomePage.jsx — full file
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
LineWhat 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 : trendingPicks 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.popularityDescending 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
Observe
CheckpointSort buttons appear. Clicking "rating" reorders the grid by score. Clicking "release date" puts newest first. Genre filter state exists but nothing populates it yet — that's Step 7. ✅
STEP 6useCallback — stable handlers for memoized children
Why useCallback here
The problem we're solvingSearchBar 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.
Goal

Wrap handlers in useCallback so memoized child components don't re-render when unrelated state changes in the parent.

Code
src/pages/HomePage.jsx — full file
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
LineWhat 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 depsBoth 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
Common mistakes
Using useCallback for every functionAfter learning this, some trainees wrap every function in useCallback. Remind them: useCallback has overhead — dep comparison on every render and storing the function. Only use it when the function goes to a React.memo child or is in a useEffect dep array. The sort buttons use inline arrow functions on regular divs — they don't need useCallback.
Q & A
What's the difference between useMemo and useCallback?
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.
Is it always wrong to put an inline arrow function in JSX?
No. For non-memoized children, an inline arrow function is fine — the child re-renders anyway when the parent does, so a new function reference doesn't matter. Only wrap in useCallback when the child is wrapped in React.memo, or the function is in a useEffect dependency array.
STEP 7GenreFilter component
Goal

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.

Code
src/components/GenreFilter.jsx — full file
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
LineWhat 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] depOnly 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
Observe
CheckpointGenre buttons appear below the search bar. Click "Action" — grid narrows to action movies. Click "All" — all movies return. Combine with sort — genres filter first, sort applies on top. ✅
STEP 8Profiler demo — make the effect visible
Goal

Show trainees the before and after in React DevTools Profiler. This converts abstract knowledge into something observable.

Part 1 — with useCallback (current state):

  1. Open React DevTools → Profiler → click Record
  2. Type a character in the search box
  3. Stop recording — SearchBar should NOT appear highlighted (skipped re-render)
  4. Point to the console — SearchBar's console.log did not fire

Part 2 — temporarily remove useCallback:

  1. Change onChange={handleQueryChange} back to onChange={(val) => setQuery(val)}
  2. Record again, type a character — SearchBar now appears in the flamegraph and the console logs
  3. Revert and explain: the inline function is a new object every render → React.memo sees a prop change → re-renders
Q & A
If SearchBar is simple, why bother preventing re-renders?
For training, we do this to make the concept concrete. In production, profile first. The real value of these hooks isn't always raw performance — it's control. useCallback lets you declare "this function's identity only changes when X changes," which is important when children have side effects (analytics, expensive DOM operations) you don't want firing on every parent keystroke.
I see a "Memo ✨" badge in DevTools on some components. What is that?
That's the React Compiler's automatic memoization badge. Since your project uses the React Compiler, some components are being automatically memoized without you writing useMemo or useCallback. We write the hooks manually in this training so you understand what the compiler is doing for you.

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.

STEP 1Concept — actions, reducers, dispatch
Write this on the whiteboard first
// 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 whenUse useReducer when
Single independent valuesMultiple related values that change together
Simple next-state logicMany action types with different transitions
e.g. a loading flag, a query stringe.g. a cart, a watchlist, a complex form
STEP 2Write the reducer function
Goal

A pure function — no side effects, no async, no API calls — that handles all watchlist and favourites state transitions.

Code
src/store/watchlistReducer.js — full file
export 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
LineWhat it does
export const INITIAL_STATEThe 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 stateUnknown 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
Common mistakes
Mutating state directlyVery common: 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.
Q & A
Can I do async work inside a reducer?
No. Reducers must be pure synchronous functions. Do your async work (API call, setTimeout) in a useEffect or event handler, get the result, then dispatch an action with the data as payload.
What does "pure function" mean exactly?
A pure function: (1) given the same inputs always returns the same output, (2) has no side effects — it doesn't modify external variables, make network requests, or update the DOM. Our reducer qualifies: same state + same action always gives the same new state. No API calls, no localStorage writes.
STEP 3Action creator helpers
Goal

Functions that return action objects — prevents typos in type strings and makes dispatch calls readable.

Code
src/store/actions.js — full file
export 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
LineWhat 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: idaddToWatchlist sends the full movie object (reducer needs all fields). removeFromWatchlist sends just the id (reducer only needs it to filter)
Why action creators?Compare: 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.
STEP 4Test the reducer in the browser console
Goal

Show trainees that a reducer is a plain JavaScript function — no React needed to test it.

Paste this into the browser console step by step
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 ✓
Q & A
When does the reducer get connected to the UI?
In Chapter 6 when we create WatchlistContext. The reducer exists as a pure function right now. Chapter 6 wraps it in 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.

STEP 1Concept — what makes a custom hook

Three requirements for a custom hook:

  1. Name starts with use — required by the ESLint plugin. Without it, rules-of-hooks lint checks don't apply to the function
  2. Calls at least one React hook inside it
  3. Is a regular JavaScript function — no special registration or class
Key insight to communicate: hooks share logic, not stateTwo components that both call 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 buildingProblem it solvesUsed by
useDebounceAPI fires on every keystroke — 10 requests per seconduseMovies
useLocalStorageWatchlist disappears on page refreshWatchlistContext
useMoviesFetch logic repeated in every component that needs moviesHomePage
STEP 2useDebounce
Show this timeline on the whiteboard
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.

Code
src/hooks/useDebounce.js — full file
import { 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
LineWhat 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] depsRe-run whenever value or delay changes. Since value changes on every keystroke, this effect runs and resets the timer on every keystroke
return debouncedThe caller uses this in their dependency array instead of the raw value, so effects only fire when the user pauses
Common mistakes
setTimeout without cleanupWithout 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.
STEP 3useLocalStorage
Goal

A drop-in replacement for useState that reads from and writes to localStorage automatically.

Code
src/hooks/useLocalStorage.js — full file
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
LineWhat 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) : initialValueIf stored data exists, parse it back to a JS value. If not, use the provided initial value
try/catch around localStoragelocalStorage 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) : valueSupports 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)
Q & A
Why does this not re-read from localStorage on every render?
The lazy initialiser — passing a function to useState — only runs once on mount, not on every render. localStorage reads are synchronous and fast, but it's still correct to avoid unnecessary reads.
What if localStorage is unavailable (private browsing)?
The try/catch in both the initialiser and setValue catches the SecurityError and falls back to initialValue. The app continues to work — the watchlist just won't persist between sessions.
STEP 4useMovies — encapsulate all fetch logic
Goal

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.

Code
src/hooks/useMovies.js — full file
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
LineWhat 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 depsThe 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
STEP 5Refactor HomePage to use useMovies
Goal

Replace all the fetch state and effects in HomePage with a single hook call. Show trainees the before and after.

Code
src/pages/HomePage.jsx — full file
import { 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
LineWhat 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 callsThose now live inside useMovies. The component is leaner and its purpose is clearer
displayMovies.length === 0 && queryShows "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
Observe
CheckpointEverything works exactly as before. Open Network tab and type quickly — the API is only called after you pause 400ms. The debounce is working through the useMovies → useDebounce chain. ✅
Q & A
Can useMovies be used in another component?
Yes — any component can call 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.

STEP 1Create WatchlistContext
The three-step persistence pattern
How the three pieces fit together1. useLocalStorage reads saved state from localStorage on mount (previous session's watchlist). 2. useReducer is initialised with that saved state instead of an empty INITIAL_STATE. 3. A useEffect watches for state changes and writes back to localStorage after each dispatch. Together these three steps make the watchlist persist across page refreshes automatically.
Code
src/context/WatchlistContext.jsx — full file
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
LineWhat 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
Common mistakes
Infinite loop with the persistence effectIf you see an infinite re-render loop, the most likely cause is that 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.
Q & A
Why is WatchlistProvider inside AuthProvider?
WatchlistProvider is a descendant of AuthProvider, so it can call 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.
What's the difference between this pattern and Redux?
Context + useReducer is local and built-in — no extra library. Redux adds middleware (thunks/sagas for async), time-travel debugging, and stricter conventions. For small-to-medium apps this pattern is sufficient. For large apps with complex async flows, Redux Toolkit is worth the overhead.
STEP 2Wire WatchlistProvider into main.jsx
Code
src/main.jsx — full file
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
LineWhat it does
WatchlistProvider inside AuthProviderWatchlistProvider is a descendant of AuthProvider, so WatchlistContext can call useAuth() if needed. Every component inside App can now call both useAuth() and useWatchlist()
STEP 3Update MovieCard with watchlist buttons
Goal

Add watchlist and favourites toggle buttons. WatchlistContext now exists so MovieCard can call useWatchlist().

Code
src/components/MovieCard.jsx — full file
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
LineWhat 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-1Makes the watchlist button fill remaining space after the fixed-width heart button
Observe
CheckpointClick "+ Watchlist" on any movie — button turns red and shows "✓ Watchlist". Click ♥ — heart turns yellow. Refresh the page — both are still active (localStorage persisted them). ✅
STEP 4Build WatchlistPage
Code
src/pages/WatchlistPage.jsx — full file
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
LineWhat it does
b.addedAt - a.addedAtDescending 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
STEP 5Day 1 checkpoint
Run through the complete Day 1 flow with trainees
  1. Sign in with Google — avatar appears in Navbar
  2. Browse home page — trending movies in a grid
  3. Click a genre — grid narrows
  4. Click a sort button — movies reorder
  5. Type in the search box — pause 400ms — results appear (debounced, check Network tab)
  6. Click "+ Watchlist" on a movie — button turns red
  7. Click ♥ on another movie — heart turns yellow
  8. Navigate to /watchlist — both lists appear, sorted by most recently added
  9. Refresh the page — watchlist and favourites are still there ← localStorage
  10. Click "Clear all" — watchlist empties
  11. Sign out — avatar gone, Profile link gone

If all of these work, every Day 1 concept has a working, observable outcome. ✅


Reference

Day 1 Final File Tree

cinetrack/ ├── .env ├── .gitignore ├── index.html ├── vite.config.js ├── package.json └── src/ ├── main.jsx ├── App.jsx ├── index.css ├── lib/ │ ├── firebase.js │ └── tmdb.js ├── context/ │ ├── AuthContext.jsx │ └── WatchlistContext.jsx ├── store/ │ ├── watchlistReducer.js │ └── actions.js ├── hooks/ │ ├── useDebounce.js │ ├── useLocalStorage.js │ └── useMovies.js ├── components/ │ ├── Navbar.jsx │ ├── MovieCard.jsx │ ├── SearchBar.jsx │ └── GenreFilter.jsx └── pages/ ├── HomePage.jsx ├── MovieDetailPage.jsx ← still stub, filled on Day 2 ├── WatchlistPage.jsx └── ProfilePage.jsx ← still stub, filled on Day 2