{"id":"l-rf-4-1","courseId":"react-fundamentals","lang":"en","kind":"lesson","summary":"Assemble the complete Watchlist from small components, with state in one owner, derived values, handlers passed down, and the list saved to localStorage.","html":"<p>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.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">The shape of the app</h2>\n<p>Here’s the final file layout in your Vite project:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"text\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Text</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code>src/\n  main.jsx\n  App.jsx               state, handlers, derived values\n  storage.js            load and save to localStorage\n  components/\n    Header.jsx          title and &quot;N to watch&quot;\n    AddMovieForm.jsx    controlled title input\n    FilterBar.jsx       All / To watch / Watched\n    MovieList.jsx       list or empty state\n    MovieItem.jsx       one movie: toggle and remove</code></pre></div>\n<p>Only two files have logic worth testing: <code dir=\"ltr\">App.jsx</code>, which owns the state, and <code dir=\"ltr\">storage.js</code>. Every component in <code dir=\"ltr\">components/</code> 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.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Storage, isolated</h2>\n<p>Start with the module that talks to the browser:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">src/storage.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> KEY</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'watchlist.movies'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> loadMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> saved</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> JSON</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">parse</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(localStorage.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getItem</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">KEY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Array.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">isArray</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(saved) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> saved </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [];</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">catch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [];</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> saveMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">movies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    localStorage.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setItem</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">KEY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">JSON</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">stringify</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(movies));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">catch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">    // Storage full or blocked: the app still works, it just won't remember.</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>localStorage can throw (blocked in some privacy modes, full quota) and can hold anything, including data from an older version of your app. Checking <code dir=\"ltr\">Array.isArray</code> and catching errors means a bad value gives you an empty list instead of a white screen. Components never see any of this.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">App: the owner</h2>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"jsx\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">src/App.jsx</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { useEffect, useState } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'react'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { loadMovies, saveMovies } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './storage.js'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Header </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './components/Header.jsx'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> AddMovieForm </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './components/AddMovieForm.jsx'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> FilterBar </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './components/FilterBar.jsx'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> MovieList </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './components/MovieList.jsx'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> default</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> App</span><span style=\"color:#953800;--shiki-dark:#FFA657\">() </span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">movies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">setMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> useState</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(loadMovies);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">setFilter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> useState</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'all'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  useEffect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    saveMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(movies);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }, [movies]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> left</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> movies.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">m</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> !</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">m.watched).</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">length</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> visible</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> movies.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">m</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    filter </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'all'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ?</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> true</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> :</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> filter </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'watched'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ?</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> m.watched </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> !</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">m.watched,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  );</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> handleAdd</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">title</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    setMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movies, { id: crypto.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">randomUUID</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(), title, watched: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">false</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }]);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> handleToggle</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    setMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(movies.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">map</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">m</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (m.id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">m, watched: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">m.watched } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> m)));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> handleRemove</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    setMovies</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(movies.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">m</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> m.id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!==</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> id));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">main</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"app\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Header</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> left</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">left</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> total</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movies.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">length</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">AddMovieForm</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onAdd</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">handleAdd</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">FilterBar</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> value</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">filter</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onChange</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">setFilter</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">MovieList</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> movies</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">visible</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onToggle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">handleToggle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onRemove</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">handleRemove</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">main</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  );</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>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.</p>\n<p>The ids come from <code dir=\"ltr\">crypto.randomUUID()</code>, created once in <code dir=\"ltr\">handleAdd</code> and stored with the movie, exactly as the keys lesson recommended. It works on <code dir=\"ltr\">localhost</code> and over HTTPS, which covers development and Vercel.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">The pieces underneath</h2>\n<p>The list and item components are short because they only display props and forward events:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"jsx\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">src/components/MovieList.jsx</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> MovieItem </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './MovieItem.jsx'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> default</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> MovieList</span><span style=\"color:#953800;--shiki-dark:#FFA657\">({ movies, onToggle, onRemove }) </span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (movies.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">length</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ===</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">p</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"empty\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>No movies here yet.&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ul</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"movie-list\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movies.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">map</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">movie</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">        &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">MovieItem</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> key</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie.id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> movie</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onToggle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">onToggle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onRemove</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">onRemove</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      ))</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ul</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  );</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"jsx\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">src/components/MovieItem.jsx</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> default</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> MovieItem</span><span style=\"color:#953800;--shiki-dark:#FFA657\">({ movie, onToggle, onRemove }) </span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie.watched </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'movie watched'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> :</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'movie'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">span</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> className</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"title\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie.title</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">span</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> aria-pressed</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie.watched</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onClick</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> onToggle</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(movie.id)</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">        {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie.watched </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">?</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'Watched'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> :</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'Mark watched'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> aria-label</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">`Remove ${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">movie</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">title</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">}`</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onClick</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> onRemove</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(movie.id)</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">        Remove</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  );</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p><code dir=\"ltr\">AddMovieForm</code> and <code dir=\"ltr\">FilterBar</code> are the versions you built in earlier lessons, and <code dir=\"ltr\">Header</code> now receives the two numbers it shows instead of the whole list. The Remove button gets an <code dir=\"ltr\">aria-label</code> that names the movie, because a screen reader user tabbing through ten “Remove” buttons needs to know which one they’re on.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 700 230\" role=\"img\" aria-labelledby=\"l-rf-4-1-t1\"><defs><marker id=\"arrow-l-rf-4-1-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-rf-4-1-t1\">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.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"30\" width=\"150\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"85\" y=\"54\" text-anchor=\"middle\">MovieItem</text>\n  <text class=\"d-code\" x=\"85\" y=\"74\" text-anchor=\"middle\">onToggle(id)</text>\n  <rect class=\"d-box-primary\" x=\"200\" y=\"30\" width=\"170\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"285\" y=\"54\" text-anchor=\"middle\">App</text>\n  <text class=\"d-code\" x=\"285\" y=\"74\" text-anchor=\"middle\">setMovies(…)</text>\n  <rect class=\"d-box\" x=\"410\" y=\"30\" width=\"130\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"475\" y=\"63\" text-anchor=\"middle\">Re-render</text>\n  <rect class=\"d-box-success\" x=\"580\" y=\"10\" width=\"110\" height=\"44\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"635\" y=\"37\" text-anchor=\"middle\">Header</text>\n  <rect class=\"d-box-success\" x=\"580\" y=\"64\" width=\"110\" height=\"44\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"635\" y=\"91\" text-anchor=\"middle\">MovieList</text>\n  <rect class=\"d-box-accent\" x=\"410\" y=\"150\" width=\"200\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"510\" y=\"174\" text-anchor=\"middle\">Effect after commit</text>\n  <text class=\"d-code\" x=\"510\" y=\"194\" text-anchor=\"middle\">saveMovies(movies)</text>\n  <path class=\"d-arrow\" d=\"M160 58 L198 58\" marker-end=\"url(#arrow-l-rf-4-1-1)\"/>\n  <path class=\"d-arrow\" d=\"M370 58 L408 58\" marker-end=\"url(#arrow-l-rf-4-1-1)\"/>\n  <path class=\"d-arrow\" d=\"M540 50 L578 34\" marker-end=\"url(#arrow-l-rf-4-1-1)\"/>\n  <path class=\"d-arrow\" d=\"M540 66 L578 84\" marker-end=\"url(#arrow-l-rf-4-1-1)\"/>\n  <path class=\"d-arrow\" d=\"M475 86 L490 148\" marker-end=\"url(#arrow-l-rf-4-1-1)\"/>\n</svg><figcaption>One click, start to finish</figcaption></figure>\n<h2 id=\"sec-5\" tabindex=\"-1\">Why this structure holds up</h2>\n<p>Look at what each component needs in order to be checked. <code dir=\"ltr\">MovieList</code> 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. <code dir=\"ltr\">MovieItem</code> needs one movie, and clicking Remove must call <code dir=\"ltr\">onRemove</code> 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.</p>\n<p>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.</p>\n<h2 id=\"sec-6\" tabindex=\"-1\">When the handlers grow</h2>\n<p>Three one-line handlers are easy to follow. If you add editing, reordering and undo, <code dir=\"ltr\">App</code> fills up with update logic. That’s the moment to consider <code dir=\"ltr\">useReducer</code>: it moves every “how movies change” rule into one pure function, <code dir=\"ltr\">moviesReducer(movies, action)</code>, that you can test without rendering anything. Don’t start there. Start with <code dir=\"ltr\">useState</code>, and reach for a reducer when several handlers update the same state in related ways and the component gets hard to read.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Calling it done after the happy path</span></p>\n<p>You add a movie, it appears, you’re done. Then a user refreshes and loses everything, or presses Enter in an empty field and gets a blank row. Before shipping, walk every flow by hand: add with the mouse and with Enter; try an empty and a space-only title; toggle, remove and filter; refresh the page; empty the list and check the message; tab through the whole app with the keyboard alone.</p>\n</aside>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Commit at this point</span></p>\n<p><code dir=\"ltr\">git init</code>, then <code dir=\"ltr\">git add .</code> and <code dir=\"ltr\">git commit -m &quot;Watchlist: add, toggle, remove, filter, persist&quot;</code>. You’ll push this repository to GitHub before deploying, and it’s good practice to commit a working state before you start restyling it.</p>\n</aside>\n<p>The Watchlist works. It doesn’t look like much yet, which is the next lesson: styling it with Tailwind CSS.</p>\n","toc":[{"id":"sec-1","text":"The shape of the app","level":2},{"id":"sec-2","text":"Storage, isolated","level":2},{"id":"sec-3","text":"App: the owner","level":2},{"id":"sec-4","text":"The pieces underneath","level":2},{"id":"sec-5","text":"Why this structure holds up","level":2},{"id":"sec-6","text":"When the handlers grow","level":2}],"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":[{"title":"Thinking in React","url":"https://react.dev/learn/thinking-in-react"},{"title":"Extracting State Logic into a Reducer","url":"https://react.dev/learn/extracting-state-logic-into-a-reducer"},{"title":"Window.localStorage (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage"}],"quiz":[{"q":"<p>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 <code dir=\"ltr\">useState</code> calls does App need?</p>","options":[{"text":"Five, one for each of movies, filter, count, visible movies and the button’s disabled flag.","why":"The count, the visible list and the disabled flag are all calculated from movies and filter. Storing them would create copies that drift."},{"text":"One, an object holding everything.","why":"You could, but movies and filter change independently. Two separate pieces of state are simpler to update."},{"text":"Two, for movies and filter. Everything else is derived during render.","why":"Correct. The draft title lives in the form, and everything else is calculated from these two."}],"answer":2},{"q":"<p>A user marks a movie watched by clicking its button in <code dir=\"ltr\">MovieItem</code>. In what order do things happen?</p>","options":[{"text":"MovieItem sets its own <code dir=\"ltr\">watched</code> state, then tells App with an Effect.","why":"MovieItem has no state of its own in this design, and syncing state through an Effect is the pattern to avoid."},{"text":"MovieItem calls <code dir=\"ltr\">onToggle(id)</code>, App’s handler calls <code dir=\"ltr\">setMovies</code>, App re-renders, and the new props flow down to the header and the list.","why":"Correct. Events flow up to the owner, the owner updates state, and the new data flows down to every component that shows it."},{"text":"App polls the DOM for checked checkboxes and updates its state.","why":"React never reads state back from the DOM. The DOM is drawn from state, not the other way around."},{"text":"MovieItem edits the movie object it received, and React notices the change.","why":"Props are read-only, and React doesn’t detect mutations. Nothing would re-render."}],"answer":1},{"q":"<p>Why does the lesson put <code dir=\"ltr\">loadMovies</code> and <code dir=\"ltr\">saveMovies</code> in a separate <code dir=\"ltr\">storage.js</code> module?</p>","options":[{"text":"It keeps the browser API, its error handling and the storage key in one place, so components only deal with plain arrays.","why":"Correct. If you later switch to a server or IndexedDB, you change one small file, and App’s code stays the same."},{"text":"Because React components aren’t allowed to call localStorage.","why":"Components can call any browser API in handlers and Effects. Separating it is about clarity, not permission."},{"text":"Because localStorage only works in files that end in <code dir=\"ltr\">.js</code>.","why":"The file extension makes no difference to browser APIs. <code dir=\"ltr\">.jsx</code> files run the same JavaScript."}],"answer":0},{"q":"<p>After adding “Clear watched”, App has handlers for add, toggle, remove and clear watched, all calling <code dir=\"ltr\">setMovies</code> with different logic. When would <code dir=\"ltr\">useReducer</code> be worth it?</p>","options":[{"text":"Immediately, because useState shouldn’t be used for arrays.","why":"useState handles arrays perfectly well, as this whole app shows."},{"text":"Only when the app uses TypeScript.","why":"Reducers work the same in JavaScript and TypeScript; the language isn’t the deciding factor."},{"text":"Never, because useReducer is deprecated in React 19.","why":"useReducer is a current, fully supported Hook. React 19 deprecated nothing about it."},{"text":"When the update logic grows enough that collecting it in one pure function, outside the component, makes it easier to read and test.","why":"Correct. A reducer moves “how state changes” into one function you can test without rendering. With four short handlers, useState is still fine."}],"answer":3}],"exercise":{"kind":"web","mode":"react","starter":{"jsx":"import { useState } from 'react';\n\nconst INITIAL = [\n  { id: 1, title: 'Arrival', watched: true },\n  { id: 2, title: 'Past Lives', watched: false },\n  { id: 3, title: 'Dune: Part Two', watched: false },\n];\nlet nextId = 4; // in a real app: crypto.randomUUID()\n\nfunction AddMovieForm({ onAdd }) {\n  const [title, setTitle] = useState('');\n  const trimmed = title.trim();\n\n  function handleSubmit(e) {\n    e.preventDefault();\n    if (!trimmed) return;\n    onAdd(trimmed);\n    setTitle('');\n  }\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <input id=\"title\" aria-label=\"Movie title\" value={title} onChange={(e) => setTitle(e.target.value)} />\n      <button type=\"submit\" disabled={!trimmed}>Add</button>\n    </form>\n  );\n}\n\nfunction FilterBar({ value, onChange }) {\n  const options = [['all', 'All'], ['to-watch', 'To watch'], ['watched', 'Watched']];\n  return (\n    <div className=\"filters\">\n      {options.map(([key, label]) => (\n        <button key={key} aria-pressed={value === key} onClick={() => onChange(key)}>\n          {label}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nfunction MovieItem({ movie, onToggle, onRemove }) {\n  return (\n    <li>\n      <span className=\"title\">{movie.title}</span>\n      <button className=\"toggle\" aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>\n        {movie.watched ? 'Watched' : 'Mark watched'}\n      </button>\n      <button className=\"remove\" aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>\n        Remove\n      </button>\n    </li>\n  );\n}\n\nfunction MovieList({ movies, onToggle, onRemove }) {\n  if (movies.length === 0) {\n    return <p className=\"empty\">No movies here yet.</p>;\n  }\n  return (\n    <ul>\n      {movies.map((movie) => (\n        <MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />\n      ))}\n    </ul>\n  );\n}\n\nexport default function App() {\n  // Wire it up: state for movies and filter, derived values, and handlers.\n  const movies = INITIAL;\n\n  return (\n    <main>\n      <h1>Watchlist</h1>\n      <p id=\"count\">0 to watch</p>\n      <AddMovieForm onAdd={() => {}} />\n      <FilterBar value=\"all\" onChange={() => {}} />\n      <MovieList movies={movies} onToggle={() => {}} onRemove={() => {}} />\n    </main>\n  );\n}\n"},"solution":{"jsx":"import { useState } from 'react';\n\nconst INITIAL = [\n  { id: 1, title: 'Arrival', watched: true },\n  { id: 2, title: 'Past Lives', watched: false },\n  { id: 3, title: 'Dune: Part Two', watched: false },\n];\nlet nextId = 4; // in a real app: crypto.randomUUID()\n\nfunction AddMovieForm({ onAdd }) {\n  const [title, setTitle] = useState('');\n  const trimmed = title.trim();\n\n  function handleSubmit(e) {\n    e.preventDefault();\n    if (!trimmed) return;\n    onAdd(trimmed);\n    setTitle('');\n  }\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <input id=\"title\" aria-label=\"Movie title\" value={title} onChange={(e) => setTitle(e.target.value)} />\n      <button type=\"submit\" disabled={!trimmed}>Add</button>\n    </form>\n  );\n}\n\nfunction FilterBar({ value, onChange }) {\n  const options = [['all', 'All'], ['to-watch', 'To watch'], ['watched', 'Watched']];\n  return (\n    <div className=\"filters\">\n      {options.map(([key, label]) => (\n        <button key={key} aria-pressed={value === key} onClick={() => onChange(key)}>\n          {label}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nfunction MovieItem({ movie, onToggle, onRemove }) {\n  return (\n    <li>\n      <span className=\"title\">{movie.title}</span>\n      <button className=\"toggle\" aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>\n        {movie.watched ? 'Watched' : 'Mark watched'}\n      </button>\n      <button className=\"remove\" aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>\n        Remove\n      </button>\n    </li>\n  );\n}\n\nfunction MovieList({ movies, onToggle, onRemove }) {\n  if (movies.length === 0) {\n    return <p className=\"empty\">No movies here yet.</p>;\n  }\n  return (\n    <ul>\n      {movies.map((movie) => (\n        <MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />\n      ))}\n    </ul>\n  );\n}\n\nexport default function App() {\n  const [movies, setMovies] = useState(INITIAL);\n  const [filter, setFilter] = useState('all');\n\n  const left = movies.filter((m) => !m.watched).length;\n  const visible = movies.filter((m) =>\n    filter === 'all' ? true : filter === 'watched' ? m.watched : !m.watched,\n  );\n\n  function handleAdd(title) {\n    setMovies([...movies, { id: nextId++, title, watched: false }]);\n  }\n\n  function handleToggle(id) {\n    setMovies(movies.map((m) => (m.id === id ? { ...m, watched: !m.watched } : m)));\n  }\n\n  function handleRemove(id) {\n    setMovies(movies.filter((m) => m.id !== id));\n  }\n\n  return (\n    <main>\n      <h1>Watchlist</h1>\n      <p id=\"count\">{left} to watch</p>\n      <AddMovieForm onAdd={handleAdd} />\n      <FilterBar value={filter} onChange={setFilter} />\n      <MovieList movies={visible} onToggle={handleToggle} onRemove={handleRemove} />\n    </main>\n  );\n}\n"},"checks":[{"id":"count","test":"return text('#count') === '2 to watch';"},{"id":"add","test":"const titles = () => $$('li .title').map((s) => text(s)).join('|'); const row = (t) => $$('li').find((li) => text(li.querySelector('.title')) === t); const filterBtn = (t) => $$('.filters button').find((b) => text(b) === t); await input('#title', 'Paddington 2'); const __f = $('form'); if (__f) __f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); else await click($('button')); await tick(20); return titles() === 'Arrival|Past Lives|Dune: Part Two|Paddington 2' && text('#count') === '3 to watch';"},{"id":"toggle","test":"const titles = () => $$('li .title').map((s) => text(s)).join('|'); const row = (t) => $$('li').find((li) => text(li.querySelector('.title')) === t); const filterBtn = (t) => $$('.filters button').find((b) => text(b) === t); await click(row('Past Lives').querySelector('.toggle')); return text('#count') === '2 to watch' && text(row('Past Lives').querySelector('.toggle')) === 'Watched';"},{"id":"filter","test":"const titles = () => $$('li .title').map((s) => text(s)).join('|'); const row = (t) => $$('li').find((li) => text(li.querySelector('.title')) === t); const filterBtn = (t) => $$('.filters button').find((b) => text(b) === t); await click(filterBtn('Watched')); if (titles() !== 'Arrival|Past Lives') return false; await click(filterBtn('To watch')); return titles() === 'Dune: Part Two|Paddington 2';"},{"id":"remove","test":"const titles = () => $$('li .title').map((s) => text(s)).join('|'); const row = (t) => $$('li').find((li) => text(li.querySelector('.title')) === t); const filterBtn = (t) => $$('.filters button').find((b) => text(b) === t); await click(row('Dune: Part Two').querySelector('.remove')); return titles() === 'Paddington 2' && text('#count') === '1 to watch';"},{"id":"empty","test":"const titles = () => $$('li .title').map((s) => text(s)).join('|'); const row = (t) => $$('li').find((li) => text(li.querySelector('.title')) === t); const filterBtn = (t) => $$('.filters button').find((b) => text(b) === t); await click(row('Paddington 2').querySelector('.remove')); return !$('ul') && text('.empty') === 'No movies here yet.' && text('#count') === '0 to watch';"}],"title":"Assemble the Watchlist","prompt":"<p>All the components are written; <code dir=\"ltr\">App</code> isn’t wired up. Make it work:</p>\n<ul>\n<li>Keep the <strong>movies</strong> and the <strong>filter</strong> (<code dir=\"ltr\">'all'</code>, <code dir=\"ltr\">'to-watch'</code>, <code dir=\"ltr\">'watched'</code>) in state, starting from <code dir=\"ltr\">INITIAL</code> and <code dir=\"ltr\">'all'</code>.</li>\n<li><code dir=\"ltr\">#count</code> shows how many movies are unwatched, as <strong>N to watch</strong>.</li>\n<li>Adding through the form appends a new, unwatched movie.</li>\n<li><strong>Mark watched</strong> toggles a movie, <strong>Remove</strong> deletes it, and the filter buttons change which movies <code dir=\"ltr\">MovieList</code> receives.</li>\n</ul>\n<p>Only change <code dir=\"ltr\">App</code>. (Saving to localStorage is left out: the playground blocks storage. In your Vite project, use the <code dir=\"ltr\">storage.js</code> module from the lesson.)</p>\n","hints":["Two <code dir=\"ltr\">useState</code> calls: <code dir=\"ltr\">useState(INITIAL)</code> and <code dir=\"ltr\">useState('all')</code>.","Derive <code dir=\"ltr\">left</code> and <code dir=\"ltr\">visible</code> from them during render; neither should be state.","Three handlers: add (<code dir=\"ltr\">[...movies, { id: nextId++, title, watched: false }]</code>), toggle (<code dir=\"ltr\">movies.map(…)</code> with a spread), remove (<code dir=\"ltr\">movies.filter(…)</code>). Pass them and <code dir=\"ltr\">setFilter</code> down."],"explanation":"<p><code dir=\"ltr\">App</code> owns exactly two pieces of state. The count and the visible list are derived on every render, so they can never disagree with the movies. Each handler replaces the array instead of mutating it (spread to add, <code dir=\"ltr\">map</code> to toggle, <code dir=\"ltr\">filter</code> to remove), and the children only call the functions they’re given. Notice that the empty state, the <code dir=\"ltr\">aria-pressed</code> buttons and the trimmed input all came for free from components you already wrote: the wiring is all that was left.</p>\n","checkLabels":{"count":"Shows “2 to watch” at the start","add":"Adding “Paddington 2” appends it and updates the count","toggle":"Mark watched on Past Lives updates its button and the count","filter":"The Watched and To watch filters show the right movies","remove":"Remove deletes a movie and updates the count","empty":"Removing the last visible movie shows “No movies here yet.”"}},"words":722,"readMinutes":3}