Final Project & Career

Reviewed & published by Brayan K

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.

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.

What You'll Build in This Capstone

💡 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:

🎉 One App, Five Milestones

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.

You're building a Todo app that:

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:

Milestone 1 — Data Model + Render

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.

// MILESTONE 1: the data model + a render function

// Each task is an OBJECT: a unique id, the text, and a done flag.
let todos = [
  { id: 1, text: "Learn the DOM", done: true },
  { id: 2, text: "Build a todo app", done: false },
  { id: 3, text: "Deploy it", done: false },
];

// render() turns the DATA into what the user SEES.
// In a browser you'd build <li> elements; here we log each line.
function render() {
  console.log("--- My Todos ---");
  todos.forEach((todo) => {
    const box = todo.done ? "[x]" : "[ ]";   // checkbox state
    console.log(box + " " + todo.text);
  });
  console.log("Total: " + todos.length + " tasks");
}

render();

// ✅ Expected output:
// --- My Todos ---
// [x] Learn the DOM
// [ ] Build a todo app
// [ ] Deploy it
// Total: 3 tasks

Milestone 2 — Add & Remove via Events

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.

// MILESTONE 2: add + remove (the work a click handler does)

let todos = [{ id: 1, text: "Learn the DOM", done: true }];
let nextId = 2;   // keeps ids unique as we add

function render(label) {
  console.log("--- " + label + " ---");
  todos.forEach((t) => console.log((t.done ? "[x] " : "[ ] ") + t.text));
}

// ADD: change the array first, THEN re-render.
function addTodo(text) {
  todos.push({ id: nextId++, text: text, done: false });
  render("After add");
}

// REMOVE: filter() returns a NEW array without the matching id.
function removeTodo(id) {
  todos = todos.filter((t) => t.id !== id);
  render("After remove");
}

// In real code these run on button clicks:
// addButton.addEventListener("click", () => addTodo(input.value));
addTodo("Build a todo app");
removeTodo(1);

// ✅ Expected output:
// --- After add ---
// [x] Learn the DOM
// [ ] Build a todo app
// --- After remove ---
// [ ] Build a todo app

🎯 Your Turn #1 — Toggle a task done

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.

// 🎯 YOUR TURN — fill in the blanks marked with ___

let todos = [
  { id: 1, text: "Learn the DOM", done: false },
  { id: 2, text: "Build a todo app", done: false },
];

function render() {
  todos.forEach((t) => console.log((t.done ? "[x] " : "[ ] ") + t.text));
}

function toggleTodo(id) {
  // 1) find the task whose id matches
  const task = todos.find((t) => t.id === ___);   // 👉 replace ___ with the parameter name (id)

  // 2) flip its done flag from true->false or false->true
  task.done = ___;                                // 👉 replace ___ with !task.done

  render();
}

toggleTodo(1);

// ✅ Expected output:
// [x] Learn the DOM
// [ ] Build a todo app

Milestone 3 — Persist to localStorage

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.

// MILESTONE 3: persistence (round-trip through a string)

// In a real browser you'd use the global localStorage.
// Here we fake it so the example runs anywhere:
const fakeStorage = {};
const localStorage = {
  setItem: (k, v) => { fakeStorage[k] = v; },
  getItem: (k) => (k in fakeStorage ? fakeStorage[k] : null),
};

let todos = [
  { id: 1, text: "Learn the DOM", done: true },
  { id: 2, text: "Build a todo app", done: false },
];

// SAVE: an array can't be stored directly — turn it into a string first.
function save() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

// LOAD: read the string back and parse it into a real array.
// If nothing was saved yet, fall back to an empty array.
function load() {
  const saved = localStorage.getItem("todos");
  return saved ? JSON.parse(saved) : [];
}

save();                       // persist current list
const restored = load();      // simulate reopening the app

console.log("Saved string:", localStorage.getItem("todos"));
console.log("Restored count:", restored.length);
console.log("First task:", restored[0].text);

// ✅ Expected output:
// Saved string: [{"id":1,"text":"Learn the DOM","done":true},{"id":2,"text":"Build a todo app","done":false}]
// Restored count: 2
// First task: Learn the DOM

Milestone 4 — Filter & Search

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.

// MILESTONE 4: filter + search WITHOUT touching the source data

const todos = [
  { id: 1, text: "Learn the DOM", done: true },
  { id: 2, text: "Build a todo app", done: false },
  { id: 3, text: "Deploy the app", done: false },
];

// getVisible() returns a NEW array — it never changes 'todos'.
function getVisible(filter, query) {
  return todos
    // status: "all", "active" (not done), or "done"
    .filter((t) => {
      if (filter === "active") return !t.done;
      if (filter === "done") return t.done;
      return true;            // "all"
    })
    // search: case-insensitive substring match
    .filter((t) => t.text.toLowerCase().includes(query.toLowerCase()));
}

console.log("Active:", getVisible("active", "").map((t) => t.text));
console.log("Search 'app':", getVisible("all", "app").map((t) => t.text));
console.log("Source untouched:", todos.length);

// ✅ Expected output:
// Active: [ 'Build a todo app', 'Deploy the app' ]
// Search 'app': [ 'Build a todo app', 'Deploy the app' ]
// Source untouched: 3

🎯 Your Turn #2 — A live task counter

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.

// 🎯 YOUR TURN — fill in the blanks marked with ___

const todos = [
  { id: 1, text: "Learn the DOM", done: true },
  { id: 2, text: "Build a todo app", done: false },
  { id: 3, text: "Deploy the app", done: false },
];

function stats() {
  // keep only tasks that are NOT done
  const remaining = todos.filter((t) => ___);   // 👉 replace ___ with !t.done

  // how many are in that filtered array?
  const count = remaining.___;                  // 👉 replace ___ with length

  console.log(count + " of " + todos.length + " left");
}

stats();

// ✅ Expected output:
// 2 of 3 left

Milestone 5 — Polish & Wire It All Together

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.

// MILESTONE 5: everything together, polished

// A fake localStorage so this runs anywhere (the browser gives you a real one).
const store = {};
const localStorage = {
  setItem: (k, v) => { store[k] = v; },
  getItem: (k) => (k in store ? store[k] : null),
};

const app = {
  todos: [],
  nextId: 1,

  load() {
    const saved = localStorage.getItem("todos");
    this.todos = saved ? JSON.parse(saved) : [];
    // keep ids unique even after a reload
    this.nextId = this.todos.reduce((max, t) => Math.max(max, t.id), 0) + 1;
  },

  save() {
    localStorage.setItem("todos", JSON.stringify(this.todos));
  },

  add(text) {
    const clean = text.trim();          // polish: no leading/trailing spaces
    if (clean === "") return;           // polish: reject empty tasks
    this.todos.push({ id: this.nextId++, text: clean, done: false });
    this.save();                        // persist after every change
    this.render();
  },

  toggle(id) {
    const task = this.todos.find((t) => t.id === id);
    if (task) task.done = !task.done;
    this.save();
    this.render();
  },

  remove(id) {
    this.todos = this.todos.filter((t) => t.id !== id);
    this.save();
    this.render();
  },

  render() {
    console.log("=== Todos ===");
    if (this.todos.length === 0) {       // polish: empty state
      console.log("(nothing yet — add a task!)");
      return;
    }
    this.todos.forEach((t) => console.log((t.done ? "[x] " : "[ ] ") + t.text));
    const left = this.todos.filter((t) => !t.done).length;
    console.log(left + " left");
  },
};

app.load();
app.add("Finish the capstone");
app.add("   ");                // ignored — validation works
app.add("Deploy to the web");
app.toggle(1);

// ✅ Expected output:
// === Todos ===
// [x] Finish the capstone
// [ ] Deploy to the web
// 1 left

Stretch Challenge — Make It Yours

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.

// 🏆 STRETCH CHALLENGE — outline only, you write the logic

// Start from the Milestone 5 'app' object, then add ONE of these:
//
//  Option A — "Clear completed"
//    1. Add a method clearDone()
//    2. Keep only tasks where !t.done  (use filter)
//    3. save(), then render()
//
//  Option B — "Edit a task"
//    1. Add a method edit(id, newText)
//    2. find() the task by id, trim newText, reject empty
//    3. set task.text = newText, then save() + render()
//
//  Option C — "Priority sort"
//    1. Give new tasks a priority number (1 = high)
//    2. In render(), sort a COPY: [...this.todos].sort((a,b) => a.priority - b.priority)
//    3. Render the sorted copy — never sort the source in place
//
// ✅ Expected: after your action, the list redraws correctly AND
//    still survives a reload (because you called save()).

// your code here

Common Pitfalls (and the Fix)

1. Storing an array directly in localStorage

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

2. Calling the handler instead of passing it

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.

5. JSON.parse on missing data

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

Quick Reference — Techniques Used in This Build

TaskTechnique
Add an item
Remove an item
Find / toggle one
Search text
Save to storage
Load from storage
Listen for clicks
Count remaining

Project Complete — You Built a Real App!

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.

Where to go next:

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

Take It Further — Bigger Capstone Projects

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

Task Manager App

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

Key Features:

Weather Dashboard

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
  • The useless string '[object Object]'
  • A JSON array

Answer: The useless string '[object Object]'. 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.

Continue this course

Frequently asked questions

Why does my app reset every time I reload the page?

If you only keep your tasks in a plain JavaScript array, they live in memory and vanish on refresh. To make them survive a reload you must save them to localStorage with localStorage.setItem('todos', JSON.stringify(todos)) and read them back with JSON.parse(localStorage.getItem('todos')) when the page first loads.

Why can't I store an array directly in localStorage?

localStorage can only hold strings. If you pass an array or object it gets coerced to the useless text '[object Object]'. Always JSON.stringify() before saving and JSON.parse() after reading so the structure is preserved.

Why does my click handler run immediately instead of on click?

You almost certainly wrote onClick={addTodo()} (with parentheses), which calls the function during render. Pass the function itself — button.addEventListener('click', addTodo) — or wrap it in an arrow function: () => addTodo(). The same rule applies in React's onClick.

Should I rebuild the whole list on every change, or update one item?

For a small app, re-rendering the entire list from your data array after every change is simplest and least bug-prone — your data is the single source of truth and the screen just mirrors it. Surgical DOM updates are faster but easy to get out of sync, so reach for them only when re-rendering becomes a measurable performance problem.

How do I add search or filtering without losing my data?

Keep the full list in one array and never delete from it when filtering. Derive a filtered view with todos.filter(...) right before rendering, and render that derived array. The original data stays intact, so clearing the search box brings every item back.

Is this capstone enough to show employers?

A todo app that persists data, handles events, and filters cleanly demonstrates the core skills of front-end work. Polish it (empty states, keyboard support, an item count), write a short README explaining your decisions, deploy it, and it becomes a legitimate portfolio piece.

Links on this page