Final Project

You'll build a complete, persistent Todo app one milestone at a time — combining the DOM, events, array methods, and localStorage into a single app you can put in your portfolio.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

💡 Running This Capstone: The editor below runs real JavaScript, so most milestones print their results with console.log so you can verify them here. To see the full app with a real input box and clickable buttons:

This is your capstone — the lesson where everything you've learned stops being separate topics and becomes a working app . Instead of reading more demos, you'll build one project the way professionals do: in small, testable milestones, each one runnable before you move on.

The golden rule for the whole build: your data array is the single source of truth. You change the array, then re-render the screen from it. Get that loop right and everything else falls into place.

📦 Real-World Analogy: Think of the app like a whiteboard and a notebook :

Every app starts with its data , not its buttons. A todo is more than a string — it needs a unique id (so you can find it later), the text , and whether it's done . So each task is an object , and your whole list is an array of those objects .

The render function reads that array and produces the on-screen list. In a real page you'd build HTML; here we'll console.log the rendered lines so you can verify the output. This render-from-data pattern is the heartbeat of the entire app.

Now make it interactive. An event is something the user does — a click, a keypress — and you respond with a handler function. Adding follows one strict order: change the array, then re-render . Removing uses filter() to build a new array without the matching item.

In a browser you'd write button.addEventListener("click", addTodo) . Below, we call the handlers directly so you can watch the array change and the list redraw — exactly what a click would trigger.

Checking a task off is its own event. Fill in the blanks so toggleTodo(id) finds the matching task and flips its done flag, then re-renders. This is the third core action your app needs.

Right now your tasks vanish on refresh because they only live in memory. localStorage is a tiny key/value store built into the browser that survives reloads — but it can only hold strings . So you JSON.stringify() the array before saving and JSON.parse() it when loading.

The pattern: save() after every change, and load() once when the app starts. The editor below has no browser localStorage, so we simulate it with a plain object to prove the stringify/parse round-trip works.

Filtering is where beginners accidentally delete their data. The fix is a mindset: never mutate the source array to filter it. Keep the full list intact and derive a view from it right before rendering, using filter() for status and includes() for a text search.

Because the original array is untouched, clearing the search box instantly brings every task back. The filter is just a lens over your real data.

Good apps tell the user how much is left. Fill in the blanks so stats() reports how many tasks remain active. Use filter() to keep only the unfinished ones, then read its length .

The final milestone assembles every piece into one tidy app object and adds the touches that make software feel finished: input that's trimmed and validated (no blank tasks), an empty state when there's nothing to show, and a save-after-every-change habit so persistence is automatic.

Read this one top to bottom — it's the whole app in miniature. Notice how add, toggle, remove, and render all flow through the same change-the-array → save → render loop you've used since Milestone 1.

Support is faded now — only an outline. Extend the app with a feature of your own and rely on the same loop you've practised: change the array, save, render.

localStorage only holds strings — always stringify on the way in, parse on the way out.

The parentheses call the function now. Pass the function itself, or wrap it: () = addTodo() .

3. Deleting from the source array when filtering

Filter into a new variable you render — never overwrite your one source of truth.

4. Forgetting to re-render after changing the data

If the screen doesn't update, you probably changed the array but never called render() . Data first, then render — every single time.

JSON.parse(null) on a brand-new browser crashes — fall back to an empty array.

You took a project from an empty array to a polished, persistent app — wiring together the DOM, events, array methods, and localStorage through one clean loop: change the data, save it, render it. That loop powers almost every front-end app you'll ever build.

Next: the Advanced Track — deeper dives into algorithms, memory, and frameworks. 🚀

You've got the core loop down. These build on the exact same skills — pick one for your portfolio.

Extend your todo app with priorities, due dates, and filters

Swap localStorage for a real API with fetch and async/await

Practice quiz

What is the 'single source of truth' in the capstone todo app?

  • The DOM
  • localStorage
  • The data array of task objects
  • The CSS

Answer: The data array of task objects. Your data array is the single source of truth — you change the array, then re-render the screen from it.

How is each task represented in the data model?

  • As an object with an id, text, and done flag
  • As a plain string
  • As a number
  • As an HTML element

Answer: As an object with an id, text, and done flag. Each task is an object with a unique id, the text, and a done boolean; the whole list is an array of these objects.

What is the core loop the whole app flows through?

  • Render, then change the array
  • Fetch, parse, encrypt
  • Click, reload, repeat
  • Change the array, save, then render

Answer: Change the array, save, then render. Every action follows change-the-array → save → render, the heartbeat of the entire app.

Which array method is used to remove a task by id?

  • push()
  • filter()
  • find()
  • sort()

Answer: filter(). removeTodo uses filter() to build a new array without the matching id.

Why must you JSON.stringify the todos before saving to localStorage?

  • Because localStorage can only hold strings
  • To encrypt them
  • To sort them
  • To make them faster

Answer: Because localStorage can only hold strings. localStorage only stores strings, so you stringify on the way in and JSON.parse on the way out.

What does saving an array directly with localStorage.setItem('todos', todos) produce?

  • A perfect copy
  • An error that stops the app

Passing an array coerces it to the useless text '[object Object]'; you must JSON.stringify first.

What is the bug in btn.addEventListener('click', addTodo())?

  • Nothing
  • The parentheses call addTodo immediately during render instead of on click
  • It needs two arguments
  • addTodo must be async

Answer: The parentheses call addTodo immediately during render instead of on click. The parentheses invoke the function now; pass the function itself (addTodo) or wrap it in an arrow function.

When filtering/searching, how do you avoid losing your data?

  • Overwrite the source array with the filtered result
  • Delete hidden tasks
  • Store the filter in the DOM
  • Keep the full array intact and derive a new filtered view to render

Answer: Keep the full array intact and derive a new filtered view to render. Never mutate the source array; derive a filtered view with filter() right before rendering so clearing the search restores everything.

Which method toggles a task's done flag in the lesson?

  • todos.push then flip
  • todos.find(t => t.id === id) then set task.done = !task.done
  • todos.filter to remove it
  • JSON.parse the task

Answer: todos.find(t => t.id === id) then set task.done = !task.done. toggleTodo finds the task by id with find(), then flips its done flag with !task.done before re-rendering.

Why guard JSON.parse with a fallback like 'saved ? JSON.parse(saved) : []'?

  • To sort the data
  • To encrypt the data
  • Because JSON.parse(null) on a brand-new browser would crash
  • To speed up parsing

Answer: Because JSON.parse(null) on a brand-new browser would crash. On a fresh browser getItem returns null, and JSON.parse(null) crashes — so you fall back to an empty array.