JavaScript

The DOM and events: build a to-do list that survives a reload

A complete, dependency-free to-do app in about 60 lines: selecting and creating elements, event delegation, keeping state in one array and rendering from it, and saving to localStorage safely.

By the TechTutorZone editorial team · Published · 6 min read (1,143 words)

The DOM (Document Object Model) is the browser's live, in-memory version of your HTML, and JavaScript changes the page by changing it. This tutorial builds a small to-do list from scratch. It is deliberately the classic example, because the classic example touches everything that matters: finding elements, reacting to events, keeping state separate from the screen, and persisting it. The finished app has no framework and no build step; it is one HTML file you can open directly.

The HTML

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>To do</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 2rem auto; padding: 0 1rem; }
    li { display: flex; gap: .5rem; align-items: center; padding: .4rem 0; }
    li.done label { text-decoration: line-through; color: #777; }
    li label { flex: 1; }
  </style>
</head>
<body>
  <h1>To do</h1>
  <form id="add">
    <label for="text">New task</label>
    <input id="text" required autocomplete="off">
    <button>Add</button>
  </form>
  <ul id="list"></ul>
  <p id="summary" aria-live="polite"></p>
  <script src="todo.js"></script>
</body>
</html>

Two decisions here save work later. Adding a task is a <form>, so pressing Enter works without any key-handling code and required blocks empty entries. The list is empty in the HTML; JavaScript renders it from data, which is the pattern that scales.

Finding elements

const form = document.querySelector('#add');
const input = document.querySelector('#text');
const list = document.querySelector('#list');
const summary = document.querySelector('#summary');

querySelector takes any CSS selector and returns the first match or null; querySelectorAll returns a static NodeList of all matches. Grab elements once at the top rather than on every event. Because the script is at the end of <body>, the elements exist when it runs; a script in the <head> would need defer or to wait for DOMContentLoaded.

State in one place

The mistake that makes DOM code unmaintainable is treating the page as the data: reading task text back out of <li> elements, counting checked boxes to know how many are done. Instead, keep an array of plain objects and make the page a function of it:

let tasks = load();   // [{ id: 1712345678901, text: 'Buy milk', done: false }, ...]

function render() {
  list.replaceChildren();                       // clear
  for (const task of tasks) {
    const li = document.createElement('li');
    li.dataset.id = task.id;
    li.classList.toggle('done', task.done);

    const box = document.createElement('input');
    box.type = 'checkbox';
    box.id = `task-${task.id}`;
    box.checked = task.done;

    const label = document.createElement('label');
    label.htmlFor = box.id;
    label.textContent = task.text;              // textContent, never innerHTML, for user text

    const remove = document.createElement('button');
    remove.type = 'button';
    remove.dataset.action = 'remove';
    remove.textContent = 'Delete';
    remove.setAttribute('aria-label', `Delete ${task.text}`);

    li.append(box, label, remove);
    list.append(li);
  }
  const left = tasks.filter(t => !t.done).length;
  summary.textContent = tasks.length ? `${left} of ${tasks.length} left` : 'Nothing to do.';
  save();
}

Every change to tasks is followed by render(), which rebuilds the list. For a few hundred items this is instant; frameworks exist for the case where it is not, and they use the same idea with a cleverer diff. textContent is used for the task text because innerHTML would interpret <img onerror=...> typed by a user as markup. That is the entire cross-site-scripting lesson in one line.

Handling events

Adding a task listens to the form's submit event. The default action of a submit is to reload the page, so it is prevented:

form.addEventListener('submit', event => {
  event.preventDefault();
  tasks.push({ id: Date.now(), text: input.value.trim(), done: false });
  input.value = '';
  render();
});

For the checkboxes and delete buttons, there could be hundreds, and they are recreated on every render, so attaching a listener to each would be wasteful and fiddly. Events bubble: a click on a button also fires on every ancestor, so one listener on the <ul> sees all of them. This is event delegation:

list.addEventListener('click', event => {
  const li = event.target.closest('li');
  if (!li) return;
  const id = Number(li.dataset.id);

  if (event.target.dataset.action === 'remove') {
    tasks = tasks.filter(t => t.id !== id);
    render();
  }
});

list.addEventListener('change', event => {
  if (event.target.type !== 'checkbox') return;
  const id = Number(event.target.closest('li').dataset.id);
  const task = tasks.find(t => t.id === id);
  task.done = event.target.checked;
  render();
});

event.target is the element that was actually clicked (which might be text inside the button); closest('li') walks up to the list item. dataset reads data-* attributes, and they come back as strings, hence Number(). Checkboxes fire change, not just click, and change also fires when they are toggled by keyboard.

Persisting with localStorage

localStorage stores strings per origin, so the array goes through JSON.stringify on the way in and JSON.parse on the way out. Both directions can fail: storage may be disabled or full, and the stored text may be corrupt or from an older version of the app. Guard both:

const KEY = 'todo.tasks';

function save() {
  try { localStorage.setItem(KEY, JSON.stringify(tasks)); }
  catch (e) { console.warn('Could not save', e); }
}

function load() {
  try {
    const parsed = JSON.parse(localStorage.getItem(KEY) ?? '[]');
    return Array.isArray(parsed) ? parsed : [];
  } catch (e) {
    return [];
  }
}

render();   // first paint from whatever was loaded

Put all of the JavaScript above into todo.js next to the HTML, in the order: element lookups, KEY and the storage functions, the tasks declaration, render, the listeners, and the final render() call. Open the HTML file, add a few tasks, reload: they are still there. Open it in a private window: the list is empty, because storage is per profile and per origin (and files opened from disk count as one odd origin; serve the folder with any local server for realistic behaviour).

Pitfalls

  • Building HTML with strings. list.innerHTML += `<li>${text}</li>` is an injection hole and also throws away every existing element's state (focus, checkbox, listeners) on each add. Create elements or use a template.
  • Listeners on elements that get re-rendered. They vanish with the element. Delegate to a stable parent.
  • Reading state from the DOM. If the array is the truth, never ask the page what the data is.
  • Forgetting type="button". A <button> inside a form submits it by default; the delete buttons here are outside the form, but the habit matters.
  • Trusting storage. Someone else's code on the same origin, an older version of yours, or a user editing it in developer tools can put anything there. Validate the shape after parsing.

Exercise

Add a filter row with three buttons, All, Active and Done, that changes which tasks render without changing the tasks array (keep the current filter in a second variable and apply it inside render). Then add a "Clear done" button. Finally, make the list editable: double-clicking a label turns it into an input, and Enter or blur saves the new text. Each feature should touch render and one listener, nothing else.

Summary

  • Find elements once with querySelector; build them with createElement and textContent.
  • Keep state in a plain array and render the whole list from it after every change.
  • Delegate events to a parent that does not get re-created; use closest and dataset to find out what was clicked.
  • Wrap localStorage reads and writes in try/catch and validate what comes back.

Next: load the tasks from a server with fetch and async/await.