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
- Model your data as an array and render it to the screen
- Add and remove items by wiring up real event listeners
- Persist everything to localStorage so it survives a refresh
- Filter and search a live list without losing your data
- Polish the app with toggles, counts, and empty states
- Combine DOM, events, array methods, and storage in one app
💡 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:
- Create an .html file with an <input>, a <button>, and a <ul>, then add your JS in a <script> tag
- Open it in your browser and use the Developer Console (Press F12) to debug
- Install Node.js if you want to run scripts outside the browser
🎉 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:
- holds tasks in a data model (an array of objects)
- lets the user add and remove tasks via real events
- remembers tasks across page reloads using localStorage
- can filter and search the list live
- is polished enough to show an employer or client
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:
- • The notebook (your data array) is the real, trusted list of tasks
- • The whiteboard (the DOM) just shows whatever is currently in the notebook
- • When something changes, you update the notebook first, then redraw the whiteboard
- • localStorage is the drawer you tuck the notebook into so it's still there tomorrow
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 tasksMilestone 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 appMilestone 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 DOMMilestone 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 leftMilestone 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 leftStretch 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 hereCommon 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
| Task | Technique |
|---|---|
| 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:
- • Ship it: rebuild this in a real HTML page, deploy it free, and add it to your portfolio with a short README.
- • Add a backend: swap localStorage for fetch() to a real API so tasks sync across devices.
- • Level up: rebuild the same app in React — you already understand the data-then-render model it's built on.
- • Keep practising: try the quiz-app variant, or the larger capstone projects below.
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:
- • Add, delete, and complete tasks
- • Priority levels & filters
- • localStorage persistence
Weather Dashboard
Swap localStorage for a real API with fetch and async/await
- • Search weather by city
- • Display temperature, humidity, wind
- • Loading & error states
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
- Previous: Checkpoint: Browser APIs
- Next: Web Components & Custom Elements — Build reusable, framework-agnostic elements with Custom Elements and Shadow DOM
- Quick reference: JavaScript cheat sheet
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.