Lesson 30 of 30

Final Project: To-Do Application

What You Are Building, and Why This One

The last lesson is a complete application: a to-do list you can add to, complete, edit, filter and delete, which remembers itself between visits. It is small enough to read in one sitting and large enough to use every important idea in this course.

A to-do list is the standard first project for a good reason. It has state that changes over time, a list that must be drawn from that state, user input that has to be validated, elements created long after the page loaded, and data that must survive a refresh. Those are the same five problems every real application has — a shopping cart, a dashboard, an attendance register — only with different labels on them.

The lessons it draws on are almost all of them: arrays and array methods for the data, objects for each item, functions and closures for the structure, the DOM lessons for rendering, events and delegation for interaction, JSON and storage for persistence, error handling for the parts that can fail. If any of those feel shaky, this is a good moment to go back to them — the project will make far more sense than another read of the lesson would.

Build it yourself before reading the finished version. Type it, break it, fix it. The value is not in owning a to-do list; it is in the twenty small decisions you have to make along the way, and in recognising the bugs when they appear — which they will, and they will be the ones this course has already named.

Example
// One task is a plain object
const exampleTask = {
  id: 1,
  text: 'Revise array methods',
  done: false
};

// The whole application state is an array of them, plus one filter
let tasks = [exampleTask];
let filter = 'all';        // 'all' | 'active' | 'done'

// Everything the user can do is one of five operations on that state
//   addTask(text)
//   toggleTask(id)
//   editTask(id, text)
//   removeTask(id)
//   clearDone()
//
// ...and every one of them ends the same way: save, then render.
Notes
  • Everything you build after this will be a bigger version of the same thing. Recognising that a cart, a comment thread and a marks register are all "a list of objects, drawn from state, changed by events" is the point of the exercise.

The Shape: State, Render, Events

The architecture is three ideas, and they are worth naming because every framework you learn later is an elaboration of exactly these three.

State is the data — an array of task objects and the current filter — and it is the single source of truth. The page is never the source of truth. If you read a task's text back out of the DOM in order to decide something, you now have two copies of the truth, and sooner or later they will disagree with each other in a way that is very hard to debug.

Render is one function that draws the page from the state. It takes nothing beyond the state and produces the whole list. Every change therefore ends the same way: change the state, then call render(). That is far easier to reason about than patching individual elements after each action, and it eliminates the whole class of bug where the page and the data quietly drift apart.

Events change the state and then re-render. One delegated listener on the list handles every row, however many exist and whenever they were created. Handlers never touch the page directly. The result is a flow that only goes one way, which makes any bug easy to locate: either the state is wrong or the render is wrong, and a single console.log(tasks) tells you which of the two you are looking at.

Example
// The one-way flow that every change follows:
//   user event  ->  update state  ->  save  ->  render()  ->  page

let tasks = [];

function addTask(text) {
  tasks = tasks.concat({ id: nextId(), text: text, done: false });
  render();
}

function toggleTask(id) {
  tasks = tasks.map(t => (t.id === id ? { ...t, done: !t.done } : t));
  render();
}

function removeTask(id) {
  tasks = tasks.filter(t => t.id !== id);
  render();
}

function render() {
  // The only function in the file that touches the list element.
}

let counter = 0;
function nextId() {
  counter = counter + 1;
  return counter;
}
Notes
  • Notice that every operation builds a new array rather than mutating the old one, using concat, map and filter. That is the immutability habit from Lesson 28, and it is what makes features like undo straightforward to add later.

Building It Step by Step

Build in an order where each step is testable on its own. Then, when something breaks, you already know it broke in the step you have just written rather than anywhere in the file.

Step one: the state, and a render function that draws a hard-coded array. No form, no events. When the two tasks you typed into the array appear on screen, rendering works and you never have to wonder about it again. Step two: add the form, and make submitting it push into the array and re-render — trimming the input and rejecting an empty one.

Step three: one delegated listener on the list. Inside it, use event.target.closest to work out both what was clicked and which row it belongs to. Step four: filtering, which is nothing more than a filter applied inside render — the state does not change at all, only what you choose to draw from it. Step five: persistence, which is the next section.

Two decisions are worth making the same way this project does. Give each task an id and put it in a data-id attribute rather than relying on its position in the list, because positions change the moment you filter or delete and an id never does. And make render rebuild the whole list rather than patching individual rows — for a list of this size it is instant, and it is far simpler to get right than tracking which rows changed.

Example
// Step 2: reading and validating input, with guard clauses
function handleAdd(event) {
  event.preventDefault();               // no page reload

  const input = document.getElementById('taskInput');
  const text = input.value.trim();      // trim at the boundary

  if (text === '') {
    showMessage('Please type something first.');
    return;
  }
  if (text.length > 100) {
    showMessage('Keep it under 100 characters.');
    return;
  }

  addTask(text);
  input.value = '';
  input.focus();                        // ready for the next one
}

// Step 3: one delegated listener for the whole list
function handleListClick(event) {
  const row = event.target.closest('li');
  if (!row || !row.dataset.id) return;  // clicked the padding - ignore

  const id = Number(row.dataset.id);
  if (event.target.closest('.delete')) return removeTask(id);
  if (event.target.closest('.edit')) return startEdit(id);
  toggleTask(id);
}

// Step 4: filtering happens in render, not in the state
function visibleTasks() {
  if (filter === 'active') return tasks.filter(t => !t.done);
  if (filter === 'done') return tasks.filter(t => t.done);
  return tasks;
}
Notes
  • Ordering the checks in handleListClick matters. The delete and edit buttons sit inside the row, so the row-level toggle has to come last — otherwise clicking Delete would also mark the task done on its way past.

Saving, and Not Breaking When You Cannot

Persistence is where Lesson 24 and Lesson 25 meet. The state is an array of objects and storage holds only strings, so JSON.stringify goes in and JSON.parse comes back out.

Save after every change that alters the state, at the end of each operation rather than inside render. Rendering happens for reasons that are not changes — switching a filter, for example — and writing to storage on each of those is wasted synchronous work on the same thread the page needs.

Load once at startup, and do it defensively. The stored value may be missing, may be corrupted, or may have been written months ago by an earlier version of your own code with a different shape. Check what comes back: is it an array, do the items carry the fields you expect. Discard anything that does not fit and fall back to an empty list, rather than letting a bad record throw inside the first render and leave a blank page.

And handle storage being unavailable at all. Private browsing, a blocked privacy setting, or a sandboxed frame can make the very first call throw a SecurityError. Wrapping storage in two small functions that catch and fall back to an in-memory copy means the application still works perfectly for that session — it simply forgets when the tab closes. That is a far better outcome than a page that shows nothing.

Example
const STORAGE_KEY = 'priodemy:tasks';
let memoryFallback = null;      // used when storage is unavailable

function saveTasks(list) {
  const text = JSON.stringify(list);
  try {
    localStorage.setItem(STORAGE_KEY, text);
  } catch (err) {
    memoryFallback = text;      // keep it for this session at least
  }
}

function loadTasks() {
  let text = memoryFallback;
  try {
    text = localStorage.getItem(STORAGE_KEY) || memoryFallback;
  } catch (err) {
    // storage unavailable - carry on with whatever is in memory
  }
  if (!text) return [];

  try {
    const parsed = JSON.parse(text);
    if (!Array.isArray(parsed)) return [];

    // Keep only records that look right, so old data cannot break render
    return parsed
      .filter(t => t && typeof t.text === 'string' && Number.isFinite(Number(t.id)))
      .map(t => ({ id: Number(t.id), text: t.text, done: Boolean(t.done) }));
  } catch (err) {
    return [];                  // corrupted - start clean rather than crash
  }
}
Notes
  • Validating on load is not paranoia. The stored data was written by your own older code, and the version of your code that reads it is the one that has to cope. This is exactly the versioning problem Lesson 25 described, solved with a filter instead of a version number.

The Complete Application

Everything above, assembled. Read it once through before running it — the structure should look familiar by now: state at the top, the storage layer beneath it, the operations, one render function, the listeners, and a single pair of lines at the bottom to start it off.

Four things are worth noticing as you read. Every handler finishes by changing state, saving, and calling render; nothing patches an element directly. The list has a single delegated click listener that covers every row, including rows that do not exist yet. Task text always reaches the page through textContent, so a task containing HTML tags is displayed as text rather than executed. And the storage layer is wrapped in try/catch, which is why the demo works even inside a sandboxed preview where localStorage is blocked.

Then try to break it deliberately, because that is where the learning is. Add an empty task. Add a task of two hundred characters. Type a script tag as a task and see it rendered as text. Delete everything and reload. Switch to Done with nothing completed. Add ten tasks, complete half, and use Clear completed. Every one of those is a case the code handles on purpose — and finding the case it does not handle is the best exercise in this lesson.

The complete to-do application
HTML
<div class="todo">
  <h3>My tasks</h3>

  <form id="addForm" class="add-row">
    <input type="text" id="taskInput" placeholder="What needs doing?" autocomplete="off">
    <button type="submit">Add</button>
  </form>
  <p id="message" class="message"></p>

  <div class="filters">
    <button type="button" data-filter="all" class="active">All</button>
    <button type="button" data-filter="active">Active</button>
    <button type="button" data-filter="done">Done</button>
  </div>

  <ul id="taskList"></ul>

  <div class="foot">
    <span id="count"></span>
    <button type="button" id="clearDone">Clear completed</button>
  </div>
</div>
CSS
.todo { max-width: 460px; padding: 20px; background: #f0f0f0; border-radius: 10px; font-family: system-ui, sans-serif; }
.todo h3 { margin: 0 0 14px; }
.add-row { display: flex; gap: 6px; }
.add-row input { flex: 1; padding: 10px; border: 1px solid #ccc; border-radius: 5px; }
button { padding: 9px 14px; background: #d1039e; color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.9rem; }
.message { color: #c0392b; font-size: 0.85rem; min-height: 1.1em; margin: 6px 0 0; }
.filters { display: flex; gap: 6px; margin: 12px 0; }
.filters button { background: #fff; color: #444; border: 1px solid #ddd; }
.filters button.active { background: #d1039e; color: #fff; border-color: #d1039e; }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; align-items: center; gap: 8px; background: #fff; padding: 10px 12px; border-radius: 6px; margin-bottom: 6px; border-left: 4px solid #d1039e; cursor: pointer; }
li.done { border-left-color: #999; }
li.done .text { text-decoration: line-through; opacity: 0.55; }
li .text { flex: 1; word-break: break-word; }
li button { background: #777; padding: 4px 9px; font-size: 0.78rem; }
li.empty { justify-content: center; color: #777; cursor: default; border-left-color: #ddd; font-size: 0.9rem; }
.foot { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; font-size: 0.85rem; color: #444; }
.foot button { background: #777; }
JavaScript
// ---------- state ----------
const STORAGE_KEY = 'priodemy:tasks';
let tasks = [];
let filter = 'all';
let nextId = 1;

// ---------- storage, wrapped so it can never break the app ----------
let memoryFallback = null;

function saveTasks() {
  const text = JSON.stringify(tasks);
  try {
    localStorage.setItem(STORAGE_KEY, text);
  } catch (err) {
    memoryFallback = text;      // blocked or unavailable - keep it in memory
  }
}

function loadTasks() {
  let text = memoryFallback;
  try {
    text = localStorage.getItem(STORAGE_KEY) || memoryFallback;
  } catch (err) {
    // storage unavailable - carry on with whatever is in memory
  }
  if (!text) return [];

  try {
    const parsed = JSON.parse(text);
    if (!Array.isArray(parsed)) return [];
    return parsed
      .filter(function (t) {
        return t && typeof t.text === 'string' && Number.isFinite(Number(t.id));
      })
      .map(function (t) {
        return { id: Number(t.id), text: t.text, done: Boolean(t.done) };
      });
  } catch (err) {
    return [];                  // corrupted - start clean rather than crash
  }
}

// ---------- elements ----------
const form = document.getElementById('addForm');
const input = document.getElementById('taskInput');
const list = document.getElementById('taskList');
const countEl = document.getElementById('count');
const message = document.getElementById('message');

// ---------- operations: change state, save, render ----------
function addTask(text) {
  tasks = tasks.concat({ id: nextId, text: text, done: false });
  nextId = nextId + 1;
  saveTasks();
  render();
}

function toggleTask(id) {
  tasks = tasks.map(function (t) {
    return t.id === id ? { id: t.id, text: t.text, done: !t.done } : t;
  });
  saveTasks();
  render();
}

function editTask(id, text) {
  tasks = tasks.map(function (t) {
    return t.id === id ? { id: t.id, text: text, done: t.done } : t;
  });
  saveTasks();
  render();
}

function removeTask(id) {
  tasks = tasks.filter(function (t) { return t.id !== id; });
  saveTasks();
  render();
}

function clearDone() {
  tasks = tasks.filter(function (t) { return !t.done; });
  saveTasks();
  render();
}

// ---------- render: the only function that touches the list ----------
function visibleTasks() {
  if (filter === 'active') return tasks.filter(function (t) { return !t.done; });
  if (filter === 'done') return tasks.filter(function (t) { return t.done; });
  return tasks;
}

function render() {
  const fragment = document.createDocumentFragment();
  const visible = visibleTasks();

  visible.forEach(function (task) {
    const li = document.createElement('li');
    li.dataset.id = task.id;
    if (task.done) li.classList.add('done');

    const span = document.createElement('span');
    span.className = 'text';
    span.textContent = task.text;   // textContent - typed tags stay as text

    const edit = document.createElement('button');
    edit.className = 'edit';
    edit.type = 'button';
    edit.textContent = 'Edit';

    const del = document.createElement('button');
    del.className = 'delete';
    del.type = 'button';
    del.textContent = 'Delete';

    li.append(span, edit, del);
    fragment.append(li);
  });

  list.innerHTML = '';            // clear once
  list.append(fragment);          // insert once

  if (visible.length === 0) {
    const empty = document.createElement('li');
    empty.className = 'empty';
    empty.textContent = tasks.length === 0
      ? 'Nothing yet. Add your first task above.'
      : 'Nothing to show in this view.';
    list.append(empty);
  }

  const left = tasks.filter(function (t) { return !t.done; }).length;
  countEl.textContent = left + ' of ' + tasks.length + ' left';
}

function showMessage(text) {
  message.textContent = text;
  setTimeout(function () { message.textContent = ''; }, 2500);
}

// ---------- events ----------
form.addEventListener('submit', function (event) {
  event.preventDefault();          // no page reload

  const text = input.value.trim(); // trim at the boundary
  if (text === '') {
    showMessage('Please type something first.');
    return;
  }
  if (text.length > 100) {
    showMessage('Keep it under 100 characters.');
    return;
  }

  addTask(text);
  input.value = '';
  input.focus();
});

// ONE delegated listener for every row, now and in the future
list.addEventListener('click', function (event) {
  const row = event.target.closest('li');
  if (!row || !row.dataset.id) return;

  const id = Number(row.dataset.id);

  if (event.target.closest('.delete')) {
    removeTask(id);
    return;
  }

  if (event.target.closest('.edit')) {
    const current = tasks.find(function (t) { return t.id === id; });
    if (!current) return;
    const next = prompt('Edit task', current.text);
    if (next !== null && next.trim() !== '') editTask(id, next.trim());
    return;
  }

  toggleTask(id);   // must come last - the buttons are inside the row
});

document.querySelector('.filters').addEventListener('click', function (event) {
  const btn = event.target.closest('button[data-filter]');
  if (!btn) return;

  filter = btn.dataset.filter;
  document.querySelectorAll('.filters button').forEach(function (b) {
    b.classList.toggle('active', b === btn);
  });
  render();
});

document.getElementById('clearDone').addEventListener('click', clearDone);

// ---------- start ----------
tasks = loadTasks();
nextId = tasks.reduce(function (max, t) { return Math.max(max, t.id); }, 0) + 1;
render();
Notes
  • The nextId line at the bottom matters more than it looks. Ids restart at 1 on every load unless you continue from the highest one already stored, and duplicate ids would make toggleTask change two rows at once — a bug that only appears after a reload, which is the worst kind to find.

Where to Take It Next

The application is complete and deliberately not finished. The extensions below are in roughly increasing order of difficulty, and each one teaches something specific rather than just adding a feature.

The last item on that list is the most instructive of all. Because the code already separates state, storage, rendering and events, moving each of those into its own module should be close to mechanical — a few export keywords and a few import lines. If it turns out to be difficult, then the separation was less clean than it looked, and discovering exactly where it leaks is worth more than the refactor itself.

After this, a framework will make far more sense than it would have before. React, Vue and the rest are elaborations of precisely the loop you have just built: state, a render derived from that state, and events that change the state. What they add is doing the rendering efficiently and automatically. What they assume you already know is everything in this course — which is exactly why students who skip the language spend months fighting the framework over problems that were never framework problems.

Finish by making it yours. Change the styling, add the one feature you personally want from a to-do list, and put it somewhere people can see it. A small application you can explain line by line, including why each decision was taken, is worth considerably more in an interview than three tutorials you followed to the end.

  • Replace prompt with an inline input: Enter to save, Escape to cancel
  • Due dates from Lesson 13, with overdue tasks highlighted
  • Sorting by date or by name — remembering to copy the array before you sort it
  • A debounced search box that filters the visible list as you type
  • Undo the last delete, by holding the removed item and a timer
  • Drag to reorder, using pointer events and transform
  • Replace storage with a server using fetch, including loading and error states
  • Split the file into modules — state, storage, render, events — and see how little has to change
Notes
  • That is the end of the course. Thirty lessons ago the goal was to be able to make a page do something; you can now build, debug, persist and reason about a complete application. Keep the console open, keep reading error messages from the first line downwards, and keep building things slightly larger than you are comfortable with.
Ask AI