RizTech Academy logo
RizTech Academy
The DOMLesson 7 of 745 min

Practice: a to-do list with no framework

Six lessons of pieces. This one assembles them into something you can actually use: a to-do list that adds, completes, deletes, filters and survives a reload, in about 120 lines with no framework.

Build it in order and run it after every step. Type it rather than pasting it — the typos you make and fix are where the learning is.

The idea that holds it together

One rule, and it decides every design question below:

An array is the truth. The page is a picture of it.

Every change updates the array, saves it, and redraws from it. Nothing is ever read back out of the DOM to find out what is going on. That sounds like extra work and is the opposite — it is the reason you never end up with a counter that disagrees with the list.

This is also, precisely, what React does. You are doing it by hand once.

Step 1: the markup

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Things to do</title>
  </head>
  <body>
    <h1>Things to do</h1>

    <form id="new-task" novalidate>
      <input type="text" id="title" placeholder="What needs doing?" required maxlength="80" />
      <button type="submit">Add</button>
      <p class="error" id="title-error"></p>
    </form>

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

    <ul id="tasks"></ul>
    <p id="count"></p>

    <script src="todo.js" defer></script>
  </body>
</html>

The list is empty. JavaScript fills it — that is what "the page is a picture of the array" means in practice.

Note type="button" on the filters. Inside a form they would submit it; these are outside one, but the habit is worth keeping. And defer, from module 1.

Step 2: storage that cannot crash the page

const STORAGE_KEY = 'tasks';

function save(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch {
    return false;
  }
}

function load(key, fallback) {
  try {
    const text = localStorage.getItem(key);
    if (text === null) return fallback;

    const parsed = JSON.parse(text);
    return Array.isArray(parsed) ? parsed : fallback;
  } catch {
    return fallback;
  }
}

The Array.isArray check is the part people leave out. Storage might hold something written by an older version of your own code, or edited by hand. You are not just guarding against corrupt text; you are guarding against valid JSON of the wrong shape. Without it, {} loads and everything downstream fails on .filter.

Step 3: the state

let tasks = load(STORAGE_KEY, []);
let filter = 'all';

const form = document.querySelector('#new-task');
const titleInput = document.querySelector('#title');
const titleError = document.querySelector('#title-error');
const list = document.querySelector('#tasks');
const count = document.querySelector('#count');
const filters = document.querySelector('#filters');

Two pieces of state and six element references, looked up once. let for the two things that change, const for everything else.

A task looks like { id, title, done }.

Step 4: render

function visibleTasks() {
  if (filter === 'active') return tasks.filter((task) => !task.done);
  if (filter === 'done') return tasks.filter((task) => task.done);
  return tasks;
}

function render() {
  list.textContent = '';

  for (const task of visibleTasks()) {
    const item = document.createElement('li');
    item.className = 'task';
    item.dataset.id = task.id;
    if (task.done) item.classList.add('is-done');

    const toggle = document.createElement('input');
    toggle.type = 'checkbox';
    toggle.checked = task.done;
    toggle.dataset.action = 'toggle';

    const label = document.createElement('span');
    label.className = 'title';
    label.textContent = task.title;

    const remove = document.createElement('button');
    remove.type = 'button';
    remove.dataset.action = 'delete';
    remove.textContent = 'Delete';

    item.append(toggle, label, remove);
    list.append(item);
  }

  const left = tasks.filter((task) => !task.done).length;
  count.textContent = `${left} of ${tasks.length} left`;
}

Four decisions worth naming.

list.textContent = '' empties the list — shorter and faster than innerHTML = '', and it cannot parse anything.

label.textContent = task.title, never innerHTML. The title came from a person. This one line is what stands between your page and the cross-site scripting hole from the selecting lesson, and step 9 proves it.

data-action on each control, so one delegated listener can tell them apart.

The count comes from tasks, not from visibleTasks() — "2 of 5 left" must mean all five, not the ones currently on screen. Reading it from list.children.length would have been wrong the moment a filter was applied. That is the "array is the truth" rule earning its keep.

Step 5: changing the state

function addTask(title) {
  tasks = [...tasks, { id: crypto.randomUUID(), title, done: false }];
  save(STORAGE_KEY, tasks);
  render();
}

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

function deleteTask(id) {
  tasks = tasks.filter((task) => task.id !== id);
  save(STORAGE_KEY, tasks);
  render();
}

The same three lines every time: change, save, render. No function touches the page directly; they all go through render. That is why the display can never drift out of step with the data.

All three build a new array rather than mutating — module 4's [...list, item], map with a spread, and filter. crypto.randomUUID() is built into every current browser and gives a unique id with no counter to maintain.

Step 6: adding

form.addEventListener('submit', (event) => {
  event.preventDefault();
  titleError.textContent = '';

  const title = titleInput.value.trim();

  if (title === '') {
    titleError.textContent = 'Type something to do first.';
    titleInput.focus();
    return;
  }

  addTask(title);
  form.reset();
  titleInput.focus();
});

titleInput.addEventListener('input', () => {
  titleError.textContent = '';
});

preventDefault first, or the page reloads. .trim() before the check, so three spaces is not a task. focus() after adding, so you can type the next one without reaching for the mouse — a small thing that makes the difference between a demo and something you would use.

The input listener clears the error as soon as they start fixing it.

Step 7: one listener for the whole list

list.addEventListener('click', (event) => {
  const control = event.target.closest('[data-action]');
  if (!control) return;

  const row = control.closest('.task');
  if (!row) return;

  if (control.dataset.action === 'toggle') toggleTask(row.dataset.id);
  if (control.dataset.action === 'delete') deleteTask(row.dataset.id);
});

One listener, on the container, for every row that exists now or later — which matters here because render destroys and rebuilds every row on every change. Per-row listeners would have to be reattached constantly and would leak.

Two closest calls: one to find which control was clicked, one to find which row it belongs to. Both guarded, so a click on the list's padding does nothing.

Step 8: filters

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

  filter = button.dataset.filter;

  for (const other of filters.querySelectorAll('[data-filter]')) {
    other.classList.toggle('is-active', other === button);
  }

  render();
});

classList.toggle(name, condition) with a second argument adds or removes to match the condition — exactly right for "only this one is active".

The filter changes a variable and re-renders. It does not hide rows with CSS, because then the DOM and the state would disagree.

Step 9: prove it is safe

Type this as a task title:

<img src=x onerror="alert(1)">

It appears as that exact text and nothing happens. No alert, and no img element in the DOM — check in the Elements panel.

Now change label.textContent = task.title to label.innerHTML = task.title, reload, and add it again. The alert fires.

Change it back. That is a one-word difference between a safe page and a compromised one, and it is worth having done once with your own hands.

Step 10: prove it recovers

Open the Application panel, find the tasks key, and replace its value with {not json at all. Reload.

The page loads, empty, and the form still works. No blank screen, no error in the console. That is what the try/catch in load bought you, and it is the difference between a bug one user hits and a bug that makes your page look broken to them forever — because a crash on load means they cannot even clear it.

What is deliberately missing

Being honest about a teaching example.

No editing — an obvious next feature, and the exercise below.

It re-renders everything on every change. For a few dozen tasks this is instant. For two thousand rows it would be visibly slow, and you would need to update only what changed. That is the problem React exists to solve, and you have now felt the shape of it.

Focus is lost on re-render. Tick a checkbox and the element the browser was focused on has been destroyed and replaced. Keyboard users notice immediately. Fixing it by hand is fiddly; frameworks handle it for you.

Nothing is shared. It is in one browser on one device. Module 8 is where data starts coming from somewhere else.

Check your work

The list starts empty in the HTML and is filled by render. The array is the truth; the page is a picture of it.

load needs the Array.isArray check, not just try/catch — valid JSON of the wrong shape breaks everything downstream just as thoroughly as corrupt text.

The count reads from tasks, not visibleTasks(). With the Done filter active, "1 of 2 left" still describes everything, which is what a person expects.

Every change does the same three things: update the array, save, render. Nothing writes to the page directly, so the display cannot drift.

All three updates build a new array — spread, map with a spread, filter — rather than mutating.

One delegated listener survives render destroying every row. Per-row listeners would need reattaching on every change.

textContent renders <img src=x onerror="alert(1)"> as literal text, with no img element created and no code run. innerHTML in the same place executes it. That one word is the whole difference.

Corrupt the stored JSON and the page still loads, empty and usable, because load falls back.

Submitting three spaces shows the error and adds nothing, because of .trim().

Practice

  1. Build it step by step, running after each one. Do not paste.
  2. Break it deliberately once. Remove event.preventDefault() and watch the page reload; remove render() from addTask and watch the count go stale while the array is correct. Put both back.
  3. Add a task, reload, and confirm it is still there. Then clear storage from devtools and reload again.
  4. Run the XSS test in step 9 both ways. Confirm textContent is safe and innerHTML is not.
  5. Run the corruption test in step 10. Confirm the page survives.
  6. Add a "Clear completed" button that removes every done task at once. One array operation, then save and render.
  7. Make the empty state friendly: when there are no tasks at all, show "Nothing to do." — and make sure it does not appear merely because a filter matched nothing. Those are two different empty states, and telling them apart is the point.
  8. Show the count as "All done." when nothing is left.
  9. Add editing. Double-clicking a title turns it into an input; Enter saves, Escape cancels, and an empty title deletes the task. Keep the rule — change the array, save, render. This is the hardest thing in the module and the most worthwhile.
  10. Harder. Add drag-free reordering with Move up and Move down buttons. Then notice that reordering is the one operation where re-rendering everything feels wrong, and write a sentence about why — that instinct is what the next framework you learn is built around.

That is module seven, and the first thing in this course you could show someone. You can find elements, change them safely, respond to a person, handle a form, and keep data between visits — and you have done it with one array, one render function and three listeners.

The habit to carry forward is the one at the top: state is the truth, the page is a picture of it. Every front-end framework you meet is an implementation of that sentence.

Next module: asynchronous JavaScript. Your to-do list knows only what is in this browser. Module 8 is how a page gets data from somewhere else — and why that turns out to be the hardest idea in the language.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship