Magyar zászlóMagyar

Practice 3 - Movie List Manager

2025-02-27 7 min read GitHub

Introduction

This practice brings together everything we have learned so far — objects, array methods, DOM manipulation, and event handling — into a single, cohesive project: a Movie List Manager.

By the end of this session you will be able to:

  1. Use array methods on arrays of objectsmap(), reduce(), filter()
  2. Render data dynamically — convert a JavaScript array into HTML with innerHTML
  3. Handle the input event for live, keystroke-by-keystroke search
  4. Understand event bubbling — why a click travels up the DOM tree
  5. Apply event delegation — one listener on the parent instead of one per child
  6. Capture and validate form input — read values, check them, build a new object

The Data

We start with a hard-coded array of movie objects. Each object has four properties:

const movies = [
  { title: "Inception",       genre: "Sci-Fi",   rating: 8.8, watched: true  },
  { title: "The Dark Knight", genre: "Action",   rating: 9.0, watched: true  },
  { title: "Interstellar",    genre: "Sci-Fi",   rating: 8.6, watched: false },
  { title: "Parasite",        genre: "Thriller", rating: 8.6, watched: true  },
  { title: "The Matrix",      genre: "Sci-Fi",   rating: 8.7, watched: false },
];

This is the shape of data you will receive from real APIs: an array of objects where every object has the same keys. Once you can work with this structure, you can work with most real-world data.


Project Structure

p3/
├── index.html   # HTML scaffold (given)
└── movies.js    # Everything we write

HTML scaffold

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Movie List Manager</title>
    <style>
        .watched {
            color: gray;
            text-decoration: line-through;
        }
    </style>
</head>
<body>
    <h1>Movie List Manager</h1>

    <input type="text" id="search" placeholder="Search by title" />
    <ul id="movie-list"></ul>

    <hr>
    <h2>Add a new movie</h2>

    <input type="text"   id="new-title"  placeholder="Title" />
    <input type="text"   id="new-genre"  placeholder="Genre" />
    <input type="number" id="new-rating" min="0" max="10" step="0.1" placeholder="Rating" />
    <button id="add-btn">Add Movie</button>

    <script src="./movies.js"></script>
</body>
</html>

The .watched CSS class handles all the visual styling — JavaScript only adds or removes the class, never touches style directly.


Step 1 — Array Methods Warm-up

Before touching the DOM, we warm up with array methods on our movies data. Open the browser console (F12) to see the output.

a) Extract all titles with .map()

let titles = movies.map((movie) => movie.title);
console.log(titles);
// ["Inception", "The Dark Knight", "Interstellar", "Parasite", "The Matrix"]

b) Calculate the average rating with .reduce()

let sumOfRatings = movies.reduce((acc, movie) => acc + movie.rating, 0);
let avgOfRatings = sumOfRatings / movies.length;
console.log("Avg: " + avgOfRatings);

reduce() collapses the whole array into a single value. The callback receives the running accumulator acc and the current movie; we return the updated accumulator each step.

c) Find unwatched movies with .filter()

let notWatched = movies.filter((movie) => !movie.watched);
console.log(`You have not watched ${notWatched.length}, and these are: `, notWatched);

.filter() returns a new array containing only the elements for which the callback returns true. The original movies array is never changed.


Step 2 — Rendering the List

Now we move to the DOM. The core idea: JavaScript holds the real data, the DOM is just a view of it.

First, select the elements we need:

const ul          = document.querySelector("#movie-list");
const searchInput = document.querySelector("#search");
const addBtn      = document.querySelector("#add-btn");

Then write a renderMovies() function that takes an array and builds the list HTML:

function renderMovies(list) {
    ul.innerHTML = list
        .map(
            (movie) => `
                <li class="${movie.watched ? "watched" : ""}">
                    <strong>${movie.title}</strong> - ${movie.genre} - ${movie.rating}
                </li>
            `,
        )
        .join("");
}

renderMovies(movies);

What is happening here?

  • .map() converts each movie object into an <li> HTML string
  • .join("") concatenates the array of strings into one big string (no separator between items)
  • ul.innerHTML = ... replaces the <ul>’s entire content with that string

The ternary for the CSS class

class="${movie.watched ? "watched" : ""}"

If the movie has been watched, we add the class "watched" — which CSS then renders as gray strikethrough. Otherwise the class is an empty string (no class). This pattern keeps all styling in CSS and all logic in JavaScript.

ℹ️

We call renderMovies(movies) once immediately at the bottom of the file, so the page shows the list as soon as it loads.


Step 3 — Live Search with the input Event

The "input" event fires on every keystroke as the user types — unlike "change" which only fires when the field loses focus.

searchInput.addEventListener("input", function () {
    const query = searchInput.value.toLowerCase();

    const filtered = movies.filter((movie) =>
        movie.title.toLowerCase().includes(query),
    );

    renderMovies(filtered);
});

Key points

  • We convert both the search query and the movie title to lowercase so the search is case-insensitive ("dark" matches "The Dark Knight")
  • String.includes(substring) returns true if the substring appears anywhere in the string
  • We filter the original movies array every time — we never modify it. The filtered result is only passed to renderMovies(); the source of truth stays intact

Step 4 — Event Bubbling

Before we add the click-to-toggle feature, it is important to understand event bubbling — a fundamental concept of how events propagate in the DOM.

When you click on an <li> inside the <ul>, the click event does not stop at the <li>. It bubbles up through every ancestor:

<li>  →  <ul>  →  <body>  →  <html>  →  document
Event bubbling diagram showing a click on an li element propagating up through ul, body, and document
Event bubbling: a click on any child element travels upward through every ancestor in the DOM tree.
Event bubbling diagram showing a click on an li element propagating up through ul, body, and document

Event bubbling: a click on any child element travels upward through every ancestor in the DOM tree.

You can see this yourself — both of these listeners fire when you click a movie:

ul.addEventListener("click", function (event) {
    console.log("The <ul> heard the click!");
});

document.body.addEventListener("click", function (e) {
    console.log("Delegation from body");
});

Even though you clicked an <li>, the <ul> and <body> handlers both fire. This happens because the browser notifies every ancestor in order, from the clicked element all the way up to the document.

This behaviour — event bubbling — is what makes event delegation possible.


Step 5 — Event Delegation: Toggle “Watched”

We want a click on any <li> to toggle its "watched" class (and therefore the strikethrough style).

The naive approach — don’t do this:

// ❌ Individual listeners per <li>
const allLis = document.querySelectorAll("li");
allLis.forEach((li) => {
    li.addEventListener("click", function (e) { ... });
});

Two problems:

  1. With many items, this registers many separate event listeners — wasteful
  2. After renderMovies() replaces innerHTML, the new <li> elements have no listeners at all

The smart approach — event delegation:

// ✅ One listener on the parent <ul>
ul.addEventListener("click", function (event) {
    const clickedLi = event.target.closest("li");
    console.log(clickedLi);

    clickedLi.classList.toggle("watched");
});

Because clicks on an <li> bubble up to the <ul>, one listener on the <ul> catches every click. It does not matter when the <li> was created — new items added later work immediately with no extra setup.

event.target and .closest()

  • event.target is the exact element the user clicked on. If there is a <strong> tag inside the <li>, clicking the bold text gives you the <strong>, not the <li>
  • .closest("li") walks up the DOM from event.target and returns the nearest ancestor (or self) that matches the CSS selector — guaranteeing we always get the <li> regardless of which child was clicked

classList.toggle()

classList.toggle("watched") adds the class if it is missing, removes it if it is present — a one-liner replacement for an if/else add/remove block.


Step 6 — Adding a New Movie

The add-movie form reads from three inputs, validates them, creates a new movie object, and re-renders.

addBtn.addEventListener("click", function () {
    let newTitle  = document.querySelector("#new-title").value;
    let newGenre  = document.querySelector("#new-genre").value;
    let rating    = document.querySelector("#new-rating").value;

    // Validate: all fields must be filled in and rating must be 0–10
    if (!newTitle || !newGenre || !rating || rating < 0 || rating > 10) {
        return;
    }

    const newMovie = {
        title:   newTitle,
        genre:   newGenre,
        watched: false,
        rating,           // shorthand for rating: rating
    };

    movies.push(newMovie);
    renderMovies(movies);
});

Object shorthand property

When a property name and the variable holding its value share the same name, you can write it once:

// Verbose:
const newMovie = { rating: rating };

// Shorthand (same result):
const newMovie = { rating };

Validation with early return

if (!newTitle || !newGenre || !rating || rating < 0 || rating > 10) {
    return; // stop — do not add the movie
}

An empty string is falsy in JavaScript, so !newTitle is true when the field is empty. The return statement exits the function early, preventing an invalid movie from being added.

Why delegation makes this free

After movies.push(newMovie) and renderMovies(movies), the new <li> appears in the list. Because our click listener is on the <ul> (not on individual <li> elements), the new item is immediately clickable to toggle watched status — no extra code needed.


Summary

ConceptWhat we used
Array methods on objectsmap(), reduce(), filter()
Dynamic renderinginnerHTML + template literals + .join("")
Live search"input" event + String.includes()
Event bubblingclick travels <li> → <ul> → <body>
Event delegationone listener on <ul>, event.target.closest("li")
Toggle a classclassList.toggle("className")
Object shorthand{ rating } instead of { rating: rating }
Validation + early returnif (!field) return;

The rendering pattern

This is the most important pattern from this session:

JS array (source of truth)
     ↓  renderMovies()
  DOM <ul> (just a view)

Never read data back out of the DOM. Keep the real state in a JavaScript variable, and rebuild the DOM from it whenever something changes. This makes search, filtering, and adding trivially composable — you just call renderMovies() with whatever array you want to display.


Practice Exercises

Exercise 1: Count unwatched in the UI

Add a <p id="stats"> to the HTML. After every call to renderMovies(), update its text to show how many movies in the original movies array have watched: false.

Hints:

  • Select #stats with querySelector
  • Use .filter() on movies (not on list) to count unwatched entries
  • Update stats.innerText inside renderMovies()

Exercise 2: Filter by genre

Add a <select id="genre-filter"> with options: All, Sci-Fi, Action, Thriller. When the selection changes, re-render showing only movies of that genre (or all movies if "All" is selected).

Hints:

  • Use the "change" event on the <select>
  • Read the selected value with genreSelect.value
  • If value === "All", call renderMovies(movies); otherwise filter first

Exercise 3: Sort by rating

Add a <button id="sort-btn">Sort by rating</button>. When clicked, re-render the list sorted from highest to lowest rating.

Hints:

  • Array.sort() mutates the original array — consider sorting a copy: [...movies].sort(...)
  • The comparator for descending order: (a, b) => b.rating - a.rating