Skip to main content
    Courses/JavaScript/Final Project & Career Roadmap

    Lesson 15 • Expert • Capstone

    Capstone: Build a Todo App End-to-End

    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.

    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 — Model the data and render it

    Each task is an object; the list is an array. render() reads the array and draws it.

    Try it Yourself »
    JavaScript
    // 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 = t
    ...

    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 and remove with handlers

    addTodo() pushes a new object then renders; removeTodo() filters it out then renders.

    Try it Yourself »
    JavaScript
    // 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 N
    ...

    🎯 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 #1 — toggleTodo()

    Find the task by id and flip its done flag, then render.

    Try it Yourself »
    JavaScript
    // 🎯 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.d
    ...

    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 — Save and load with JSON

    JSON.stringify() to save, JSON.parse() to load. localStorage only stores strings.

    Try it Yourself »
    JavaScript
    // 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
    ...

    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 — Derive a filtered view

    filter() by status and includes() for search — the source array stays whole.

    Try it Yourself »
    JavaScript
    // 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"
    ...

    🎯 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 #2 — count remaining tasks

    Filter to the active tasks, then report how many are left.

    Try it Yourself »
    JavaScript
    // 🎯 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(cou
    ...

    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 — The complete app, polished

    One app object: validate input, save on every change, render with an empty state.

    Try it Yourself »
    JavaScript
    // 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)
    ...

    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 — add a feature

    Outline only. Pick one feature and build it using change → save → render.

    Try it Yourself »
    JavaScript
    // 🏆 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 so
    ...

    Common Pitfalls (and the Fix)

    1. Storing an array directly in localStorage

    localStorage.setItem("todos", todos);       // ❌ saves "[object Object]"
    localStorage.setItem("todos", JSON.stringify(todos)); // ✅

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

    2. Calling the handler instead of passing it

    btn.addEventListener("click", addTodo());   // ❌ runs immediately
    btn.addEventListener("click", addTodo);     // ✅ runs on click

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

    3. Deleting from the source array when filtering

    // ❌ search throws away the hidden tasks forever
    todos = todos.filter(t => t.text.includes(query));
    
    // ✅ keep the source whole; derive a view to render
    const visible = todos.filter(t => t.text.includes(query));

    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

    const saved = localStorage.getItem("todos");
    const todos = saved ? JSON.parse(saved) : [];   // ✅ guard against null

    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 itemtodos.push({ id, text, done: false })
    Remove an itemtodos = todos.filter(t => t.id !== id)
    Find / toggle onetodos.find(t => t.id === id)
    Search textt.text.toLowerCase().includes(q)
    Save to storagelocalStorage.setItem("todos", JSON.stringify(todos))
    Load from storageJSON.parse(localStorage.getItem("todos") || "[]")
    Listen for clicksbtn.addEventListener("click", handler)
    Count remainingtodos.filter(t => !t.done).length

    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.

    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.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service