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.
- 1The shape of the app
- 2Storage, isolated
- 3App: the owner
- 4The pieces underneath
- 5Why this structure holds up
- 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:
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 removeOnly 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:
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
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:
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>
);
}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.
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.