EduFlow
React Fundamentals
0%

Section 4 · Shipping a Project

Building the Watchlist App

26 min3 min readCode playground

Lesson at a glance

Assemble the complete Watchlist from small components, with state in one owner, derived values, handlers passed down, and the list saved to localStorage.

  1. 1The shape of the app
  2. 2Storage, isolated
  3. 3App: the owner
  4. 4The pieces underneath
  5. 5Why this structure holds up
  6. 6When the handlers grow

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Every piece of the Watchlist exists now, scattered across lessons: components from Section 1, props and lists from Section 2, state, forms and Effects from Section 3. This lesson puts them together into one app you’d be comfortable showing in an interview, and explains each decision so you can make the same ones in your next project.

The shape of the app

Here’s the final file layout in your Vite project:

Text
src/
  main.jsx
  App.jsx               state, handlers, derived values
  storage.js            load and save to localStorage
  components/
    Header.jsx          title and "N to watch"
    AddMovieForm.jsx    controlled title input
    FilterBar.jsx       All / To watch / Watched
    MovieList.jsx       list or empty state
    MovieItem.jsx       one movie: toggle and remove

Only two files have logic worth testing: App.jsx, which owns the state, and storage.js. Every component in components/ receives props and returns JSX. That’s the split you want: a thin layer of state and logic at the top, and a wide layer of simple pieces underneath.

Storage, isolated

Start with the module that talks to the browser:

src/storage.js
const KEY = 'watchlist.movies';

export function loadMovies() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY));
    return Array.isArray(saved) ? saved : [];
  } catch {
    return [];
  }
}

export function saveMovies(movies) {
  try {
    localStorage.setItem(KEY, JSON.stringify(movies));
  } catch {
    // Storage full or blocked: the app still works, it just won't remember.
  }
}

localStorage can throw (blocked in some privacy modes, full quota) and can hold anything, including data from an older version of your app. Checking Array.isArray and catching errors means a bad value gives you an empty list instead of a white screen. Components never see any of this.

App: the owner

src/App.jsx
import { useEffect, useState } from 'react';
import { loadMovies, saveMovies } from './storage.js';
import Header from './components/Header.jsx';
import AddMovieForm from './components/AddMovieForm.jsx';
import FilterBar from './components/FilterBar.jsx';
import MovieList from './components/MovieList.jsx';

export default function App() {
  const [movies, setMovies] = useState(loadMovies);
  const [filter, setFilter] = useState('all');

  useEffect(() => {
    saveMovies(movies);
  }, [movies]);

  const left = movies.filter((m) => !m.watched).length;
  const visible = movies.filter((m) =>
    filter === 'all' ? true : filter === 'watched' ? m.watched : !m.watched,
  );

  function handleAdd(title) {
    setMovies([...movies, { id: crypto.randomUUID(), title, watched: false }]);
  }

  function handleToggle(id) {
    setMovies(movies.map((m) => (m.id === id ? { ...m, watched: !m.watched } : m)));
  }

  function handleRemove(id) {
    setMovies(movies.filter((m) => m.id !== id));
  }

  return (
    <main className="app">
      <Header left={left} total={movies.length} />
      <AddMovieForm onAdd={handleAdd} />
      <FilterBar value={filter} onChange={setFilter} />
      <MovieList movies={visible} onToggle={handleToggle} onRemove={handleRemove} />
    </main>
  );
}

Read it top to bottom and you know the whole app. Two pieces of state. One Effect, for the one thing outside React. Two derived values. Three handlers, each one line, each a named action a user can take. Then the layout. When a bug report comes in (“removing a movie brings it back after refresh”), you know exactly which lines could be involved.

The ids come from crypto.randomUUID(), created once in handleAdd and stored with the movie, exactly as the keys lesson recommended. It works on localhost and over HTTPS, which covers development and Vercel.

The pieces underneath

The list and item components are short because they only display props and forward events:

src/components/MovieList.jsx
import MovieItem from './MovieItem.jsx';

export default function MovieList({ movies, onToggle, onRemove }) {
  if (movies.length === 0) {
    return <p className="empty">No movies here yet.</p>;
  }

  return (
    <ul className="movie-list">
      {movies.map((movie) => (
        <MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
}
src/components/MovieItem.jsx
export default function MovieItem({ movie, onToggle, onRemove }) {
  return (
    <li className={movie.watched ? 'movie watched' : 'movie'}>
      <span className="title">{movie.title}</span>
      <button aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>
        {movie.watched ? 'Watched' : 'Mark watched'}
      </button>
      <button aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>
        Remove
      </button>
    </li>
  );
}

AddMovieForm and FilterBar are the versions you built in earlier lessons, and Header now receives the two numbers it shows instead of the whole list. The Remove button gets an aria-label that names the movie, because a screen reader user tabbing through ten “Remove” buttons needs to know which one they’re on.

Clicking Mark watched in MovieItem calls onToggle, which runs handleToggle in App. setMovies triggers a re-render; new props flow to Header and MovieList, and the save Effect writes to localStorage. MovieItem onToggle(id) App setMovies(…) Re-render Header MovieList Effect after commit saveMovies(movies)
One click, start to finish

Why this structure holds up

Look at what each component needs in order to be checked. MovieList needs an array and two functions. Give it an empty array and it must show “No movies here yet.”; give it two movies and it must render two items. MovieItem needs one movie, and clicking Remove must call onRemove with that movie’s id. None of them need localStorage, a real form or the rest of the app. That’s what “small, testable pieces” means in practice: each piece has a few inputs and a few visible outputs.

When you add automated tests later (Vitest with React Testing Library is the common pairing for Vite projects), these are the tests you’ll write first. And even before you have tests, the structure pays off: when the empty state looks wrong, you open one 10-line file, not a 300-line component.

When the handlers grow

Three one-line handlers are easy to follow. If you add editing, reordering and undo, App fills up with update logic. That’s the moment to consider useReducer: it moves every “how movies change” rule into one pure function, moviesReducer(movies, action), that you can test without rendering anything. Don’t start there. Start with useState, and reach for a reducer when several handlers update the same state in related ways and the component gets hard to read.

The Watchlist works. It doesn’t look like much yet, which is the next lesson: styling it with Tailwind CSS.

Key takeaways

  • The finished app keeps two pieces of real state in App, the movies and the filter, and derives everything else during render.
  • All changes to movies happen in a few named handlers in App, which children call through props.
  • Small presentational components that only receive props are easy to read, reuse and test.
  • Isolate browser APIs such as localStorage in a small module with error handling, so components stay simple.
  • Walk through every user flow by hand, including refresh, empty states and keyboard use, before calling it done.

Further reading

Check your understanding

4 questions

Question 1 of 4

You extend the finished Watchlist with a “Clear watched” button that’s disabled when nothing is watched, next to the count and the filtered list. How many useState calls does App need?

Question 2 of 4

A user marks a movie watched by clicking its button in MovieItem. In what order do things happen?

Question 3 of 4

Why does the lesson put loadMovies and saveMovies in a separate storage.js module?

Question 4 of 4

After adding “Clear watched”, App has handlers for add, toggle, remove and clear watched, all calling setMovies with different logic. When would useReducer be worth it?

Practice

Code playground

Assemble the Watchlist

All the components are written; App isn’t wired up. Make it work:

  • Keep the movies and the filter ('all', 'to-watch', 'watched') in state, starting from INITIAL and 'all'.
  • #count shows how many movies are unwatched, as N to watch.
  • Adding through the form appends a new, unwatched movie.
  • Mark watched toggles a movie, Remove deletes it, and the filter buttons change which movies MovieList receives.

Only change App. (Saving to localStorage is left out: the playground blocks storage. In your Vite project, use the storage.js module from the lesson.)

import { useState } from 'react';

const INITIAL = [
  { id: 1, title: 'Arrival', watched: true },
  { id: 2, title: 'Past Lives', watched: false },
  { id: 3, title: 'Dune: Part Two', watched: false },
];
let nextId = 4; // in a real app: crypto.randomUUID()

function AddMovieForm({ onAdd }) {
  const [title, setTitle] = useState('');
  const trimmed = title.trim();

  function handleSubmit(e) {
    e.preventDefault();
    if (!trimmed) return;
    onAdd(trimmed);
    setTitle('');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input id="title" aria-label="Movie title" value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit" disabled={!trimmed}>Add</button>
    </form>
  );
}

function FilterBar({ value, onChange }) {
  const options = [['all', 'All'], ['to-watch', 'To watch'], ['watched', 'Watched']];
  return (
    <div className="filters">
      {options.map(([key, label]) => (
        <button key={key} aria-pressed={value === key} onClick={() => onChange(key)}>
          {label}
        </button>
      ))}
    </div>
  );
}

function MovieItem({ movie, onToggle, onRemove }) {
  return (
    <li>
      <span className="title">{movie.title}</span>
      <button className="toggle" aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>
        {movie.watched ? 'Watched' : 'Mark watched'}
      </button>
      <button className="remove" aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>
        Remove
      </button>
    </li>
  );
}

function MovieList({ movies, onToggle, onRemove }) {
  if (movies.length === 0) {
    return <p className="empty">No movies here yet.</p>;
  }
  return (
    <ul>
      {movies.map((movie) => (
        <MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
}

export default function App() {
  // Wire it up: state for movies and filter, derived values, and handlers.
  const movies = INITIAL;

  return (
    <main>
      <h1>Watchlist</h1>
      <p id="count">0 to watch</p>
      <AddMovieForm onAdd={() => {}} />
      <FilterBar value="all" onChange={() => {}} />
      <MovieList movies={movies} onToggle={() => {}} onRemove={() => {}} />
    </main>
  );
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Shows “2 to watch” at the start
  • Adding “Paddington 2” appends it and updates the count
  • Mark watched on Past Lives updates its button and the count
  • The Watched and To watch filters show the right movies
  • Remove deletes a movie and updates the count
  • Removing the last visible movie shows “No movies here yet.”