The Codex / Projects / To-Do List / JavaScript
Tier 1 — Beginner

To-Do List

Build a browser to-do list with localStorage — CRUD, rendering, and persistence.

🐍 Python 🟨 JavaScript

1. The Problem

Build a to-do list app in the browser where users can add tasks, mark them complete, and delete them. Tasks persist across page refreshes using localStorage — so your list survives closing and reopening the browser tab.

Why this project? A to-do list is the "hello world" of stateful apps. It teaches the core CRUD loop (Create, Read, Update, Delete), how to render data as HTML, and how to persist state in the browser without a database.

2. How to Think About This

The key insight: keep the data separate from the display.

Maintain one array of task objects as the single source of truth. Every time the data changes (add/toggle/delete), re-render the entire list from scratch. This pattern — "data changes → render" — is the foundation of every modern UI framework.

tasks array → render() → HTML list
user action → update tasks array → render() again

3. The Build

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>To-Do List</title>
  <style>
    body { font-family: sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
    h1   { font-size: 1.5rem; margin-bottom: 1rem; }
    .add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
    .add-form input  { flex: 1; padding: 0.6rem 0.8rem; border: 1px solid #ccc; border-radius: 6px; }
    .add-form button { padding: 0.6rem 1rem; background: #2563eb; color: #fff;
                       border: none; border-radius: 6px; cursor: pointer; }
    .task-list { list-style: none; padding: 0; }
    .task-item { display: flex; align-items: center; gap: 10px; padding: 0.6rem 0;
                 border-bottom: 1px solid #eee; }
    .task-item input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
    .task-text  { flex: 1; }
    .task-text.done { text-decoration: line-through; color: #999; }
    .delete-btn { background: none; border: none; color: #dc2626; cursor: pointer;
                  font-size: 1.1rem; padding: 0 4px; }
    .stats { font-size: 0.85rem; color: #666; margin-top: 1rem; }
  </style>
</head>
<body>
  <h1>📝 To-Do List</h1>
  <form class="add-form" id="add-form">
    <input type="text" id="task-input" placeholder="Add a task..." required>
    <button type="submit">Add</button>
  </form>
  <ul class="task-list" id="task-list"></ul>
  <p class="stats" id="stats"></p>
  <script src="todo.js"></script>
</body>
</html>
todo.js
// ── Data ────────────────────────────────────────────────────────────────────
// Load saved tasks from localStorage, or start with an empty array
let tasks = JSON.parse(localStorage.getItem("tasks") ?? "[]");

// Save tasks to localStorage so they persist across page refreshes
function save() {
  localStorage.setItem("tasks", JSON.stringify(tasks));
}

// Create a new task object
function createTask(text) {
  return {
    id:        Date.now(),   // unique ID: milliseconds since epoch
    text:      text.trim(),
    completed: false
  };
}

// ── UI ───────────────────────────────────────────────────────────────────────
const taskList  = document.getElementById("task-list");
const statsEl   = document.getElementById("stats");

// Render the current tasks array as HTML
function render() {
  if (tasks.length === 0) {
    taskList.innerHTML = "<li style='color:#999;padding:0.5rem 0'>No tasks yet. Add one above.</li>";
  } else {
    // Build HTML for each task, then set it all at once
    taskList.innerHTML = tasks.map(task => `
      <li class="task-item">
        <input type="checkbox" ${task.completed ? "checked" : ""}
               onchange="toggle(${task.id})">
        <span class="task-text ${task.completed ? "done" : ""}">${escapeHtml(task.text)}</span>
        <button class="delete-btn" onclick="remove(${task.id})" aria-label="Delete task">✕</button>
      </li>
    `).join("");
  }

  // Update the stats line
  const done  = tasks.filter(t => t.completed).length;
  const total = tasks.length;
  statsEl.textContent = total > 0 ? `${done} of ${total} completed` : "";
}

// Escape HTML to prevent XSS when displaying user input
function escapeHtml(str) {
  return str.replace(/&/g,"&amp;").replace(/</g,"&lt;")
            .replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}

// ── Actions ──────────────────────────────────────────────────────────────────
function toggle(id) {
  tasks = tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
  save(); render();
}

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

// ── Form handler ─────────────────────────────────────────────────────────────
document.getElementById("add-form").addEventListener("submit", (e) => {
  e.preventDefault();                // stop the page from reloading
  const input = document.getElementById("task-input");
  const text  = input.value.trim();
  if (!text) return;                 // ignore empty submissions
  tasks = [...tasks, createTask(text)];  // add to front (or back with push)
  input.value = "";                  // clear the input
  save(); render();
});

// Initial render on page load
render();

What each part does

  • localStorage.getItem/setItem — browser storage that survives page refreshes
  • JSON.parse/stringify — convert between JS objects and the strings localStorage stores
  • ?? "[]" — nullish coalescing: use "[]" if nothing is stored yet
  • tasks.map(task => `...`).join("") — build HTML string for every task, join into one
  • { ...t, completed: !t.completed } — spread creates a new object (doesn't mutate original)
  • escapeHtml() — prevent XSS: user input goes to display, never to innerHTML raw
  • e.preventDefault() — stop the form from submitting to a server (default behaviour)

4. Test & Prove

todo.test.mjs
// Pure logic functions (extract from todo.js and test without DOM)
function createTask(text) {
  return { id: Date.now(), text: text.trim(), completed: false };
}
function toggle(tasks, id) {
  return tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
}
function remove(tasks, id) {
  return tasks.filter(t => t.id !== id);
}

let pass = 0, fail = 0;
function test(desc, actual, expected) {
  const ok = JSON.stringify(actual) === JSON.stringify(expected) || actual === expected;
  ok ? pass++ : fail++;
  console.log(ok ? "✓" : "✗", desc);
}

const t1 = createTask("  Buy milk  ");
const t2 = createTask("Walk dog");

test("createTask trims whitespace",          t1.text, "Buy milk");
test("new task starts incomplete",           t1.completed, false);
test("toggle marks task complete",           toggle([t1], t1.id)[0].completed, true);
test("toggle twice restores false",          toggle(toggle([t1], t1.id), t1.id)[0].completed, false);
test("toggle only affects target task",      toggle([t1, t2], t1.id)[1].completed, false);
test("remove deletes correct task",          remove([t1, t2], t1.id).length, 1);
test("remove leaves other tasks intact",     remove([t1, t2], t1.id)[0].text, "Walk dog");
test("immutability: original unchanged",     t1.completed, false); // spread preserved it

console.log(\`
\${pass} passed, \${fail} failed\`);

5. The Interface

Input

A text field and "Add" button. Checkbox to toggle. ✕ button to delete. Submitting an empty field is ignored. All task text is HTML-escaped before display.

Output

A live-updating list of tasks with visual strikethrough for completed items. A stats line ("3 of 5 completed"). Persists across page refreshes via localStorage.

6. Run It

# Open index.html directly in a browser:
open index.html

# Or serve with any local server (needed if you add ES modules):
npx serve .   # uses npx — no install needed

# Run the logic tests:
node todo.test.mjs

🎯 Try this next

  1. Filter buttons — show All / Active / Completed; only render matching tasks
  2. Drag to reorder — use the HTML Drag and Drop API to let users reorder tasks
  3. Due dates — add a date picker; highlight overdue tasks in red
  4. Categories — add a tag to each task; filter by tag; store tags as a Set

What you practised

CRUD operations on an array, localStorage for persistence, rendering data as HTML from a template literal, immutable updates with spread ({ ...task }), XSS prevention with HTML escaping, and the "data → render" pattern that underpins React, Vue, and every other UI framework.

Reference pages: DOM & Events · Arrays · Objects

Try It Yourself