What You'll Build

A working To-Do List app that lets you add tasks, mark them done, filter by status, and delete them. Everything persists — close the browser, come back tomorrow, and your list is still there. This is done with localStorage.

  • Add new tasks by typing and pressing Enter
  • Click a checkbox to mark a task done (with strikethrough style)
  • Delete individual tasks
  • Filter by All / Active / Completed
  • Counter showing tasks remaining
  • Save automatically to localStorage — survives page refresh
▶ Live Preview — this is what you'll build

What You'll Learn

Why this project matters: Every app you'll ever build — Twitter, Instagram, a project manager — has a list of items that gets rendered from data. This is that pattern, in its simplest form.

1

Build the HTML Structure

Create index.html. The layout is a text input for adding tasks, filter buttons, an empty <ul> (JavaScript fills this), and a footer with a count.

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>To-Do List</title>
</head>
<body>
    <div class="app">
        <h1>✅ My Tasks</h1>
        <p class="sub">Add a task below, press Enter</p>

        <div class="input-row">
            <input type="text" id="taskInput" placeholder="What needs to be done?">
            <button class="add-btn" id="addBtn">Add</button>
        </div>

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

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

        <div class="footer">
            <span><span class="count" id="remaining">0</span> tasks remaining</span>
            <button class="clear-btn" id="clearCompleted">Clear completed</button>
        </div>
    </div>

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

Key idea: The <ul id="taskList"> is empty in the HTML. JavaScript generates all its content. This is called a data-driven UI — the HTML is a shell, the JS fills it based on data.

2

Style It with CSS

CSS
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #f1f5f9;
    padding: 24px;
    color: #0f172a;
    min-height: 100vh;
}

.app {
    max-width: 460px;
    margin: 0 auto;
    background: #fff;
    border-radius: 16px;
    padding: 24px;
    box-shadow: 0 10px 40px rgba(15,23,42,0.08);
}

h1 { font-size: 1.4rem; margin-bottom: 4px; }
.sub { color: #64748b; font-size: 0.85rem; margin-bottom: 18px; }

.input-row { display: flex; gap: 8px; margin-bottom: 16px; }
input[type="text"] {
    flex: 1;
    padding: 12px 14px;
    border: 2px solid #e2e8f0;
    border-radius: 10px;
    font-size: 0.95rem;
    font-family: inherit;
    outline: none;
    transition: 0.2s;
}
input[type="text"]:focus {
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
.add-btn {
    background: #3b82f6;
    color: #fff;
    border: none;
    padding: 0 18px;
    border-radius: 10px;
    font-weight: 700;
    font-size: 0.9rem;
    cursor: pointer;
    transition: 0.2s;
    font-family: inherit;
}
.add-btn:hover { background: #2563eb; }

.filters { display: flex; gap: 6px; margin-bottom: 12px; }
.filter {
    background: #f1f5f9;
    border: 2px solid #e2e8f0;
    color: #64748b;
    padding: 6px 12px;
    border-radius: 8px;
    font-size: 0.78rem;
    font-weight: 700;
    cursor: pointer;
    transition: 0.2s;
    font-family: inherit;
}
.filter:hover { border-color: #cbd5e1; color: #334155; }
.filter.active {
    background: #eff6ff;
    border-color: #3b82f6;
    color: #1d4ed8;
}

ul { list-style: none; }
.task {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px;
    border-radius: 10px;
    margin-bottom: 6px;
    background: #f8fafc;
    transition: 0.2s;
}
.task:hover { background: #f1f5f9; }
.task input[type="checkbox"] {
    width: 20px;
    height: 20px;
    accent-color: #3b82f6;
    cursor: pointer;
    flex-shrink: 0;
}
.task .text {
    flex: 1;
    font-size: 0.95rem;
    color: #0f172a;
    word-break: break-word;
}
.task.done .text {
    text-decoration: line-through;
    color: #94a3b8;
}
.task .del {
    background: transparent;
    border: none;
    color: #cbd5e1;
    cursor: pointer;
    font-size: 1.1rem;
    padding: 4px;
    transition: 0.2s;
}
.task .del:hover { color: #ef4444; }

.empty {
    text-align: center;
    color: #94a3b8;
    font-size: 0.88rem;
    padding: 24px 12px;
    font-style: italic;
}

.footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid #e2e8f0;
    font-size: 0.82rem;
    color: #64748b;
}
.footer .count {
    font-weight: 700;
    color: #1d4ed8;
    font-family: "Courier New", monospace;
}
.clear-btn {
    background: transparent;
    border: none;
    color: #64748b;
    cursor: pointer;
    font-size: 0.82rem;
    text-decoration: underline;
    font-family: inherit;
}
.clear-btn:hover { color: #ef4444; }
3

Write the JavaScript — Data Model & Storage

Instead of storing tasks in the DOM, we store them in an array. The DOM is rebuilt from that array whenever data changes.

JavaScript — Part 1: State + Storage
// STATE — the single source of truth
var STORAGE_KEY = "makuistudio-todo-v1";
var tasks = [];       // Each: { id, text, done }
var filter = "all";   // "all" | "active" | "done"

var inputEl     = document.getElementById("taskInput");
var addBtn      = document.getElementById("addBtn");
var listEl      = document.getElementById("taskList");
var remainingEl = document.getElementById("remaining");
var clearBtn    = document.getElementById("clearCompleted");
var filterBtns  = document.querySelectorAll(".filter");

// STORAGE — save/load from localStorage
function load() {
    try {
        var saved = localStorage.getItem(STORAGE_KEY);
        tasks = saved ? JSON.parse(saved) : [];
    } catch (e) { tasks = []; }
}

function save() {
    try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
    } catch (e) { }
}

Why wrap localStorage in try/catch? Some browsers block localStorage in private mode, and it can throw a quota error if full. Wrapping in try/catch means your app keeps working — just without persistence.

4

Write the JavaScript — CRUD Actions

These are the four CRUD operations. Every one follows the same pattern: change the array → save → render.

JavaScript — Part 2: CRUD Actions
// CREATE — add a new task
function addTask(text) {
    text = text.trim();
    if (!text) return;
    tasks.push({
        id: Date.now(),
        text: text,
        done: false
    });
    save();
    render();
}

// UPDATE — toggle done/undone
function toggleTask(id) {
    tasks = tasks.map(function(t) {
        if (t.id === id) {
            return { id: t.id, text: t.text, done: !t.done };
        }
        return t;
    });
    save();
    render();
}

// DELETE — remove one task
function deleteTask(id) {
    tasks = tasks.filter(function(t) {
        return t.id !== id;
    });
    save();
    render();
}

// BULK DELETE — remove all completed
function clearCompleted() {
    tasks = tasks.filter(function(t) {
        return !t.done;
    });
    save();
    render();
}

Why tasks = tasks.map(...) instead of tasks[i].done = !tasks[i].done? Both work. But creating a new array (immutable update) is safer for larger apps because it prevents unexpected mutations from shared references. Get into the habit now.

5

Write the JavaScript — Render

The render() function rebuilds the entire task list from the tasks array. When data changes, we just call render again.

JavaScript — Part 3: Render
// Escape user input before inserting into HTML
// Prevents XSS — if user types "<script>", show it as text instead
function escapeHtml(str) {
    return String(str)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
}

// RENDER — rebuild the DOM from the data
function render() {
    // Apply the current filter
    var visible = tasks;
    if (filter === "active") {
        visible = tasks.filter(function(t) { return !t.done; });
    }
    if (filter === "done") {
        visible = tasks.filter(function(t) { return t.done; });
    }

    // Empty state vs. task list
    if (visible.length === 0) {
        listEl.innerHTML = '<li class="empty">' +
            (tasks.length === 0
                ? "No tasks yet - add one above!"
                : "No tasks match this filter.") +
            '</li>';
    } else {
        listEl.innerHTML = visible.map(function(t) {
            return '<li class="task' + (t.done ? " done" : "") + '" data-id="' + t.id + '">' +
                '<input type="checkbox"' + (t.done ? " checked" : "") + ' data-action="toggle">' +
                '<span class="text">' + escapeHtml(t.text) + '</span>' +
                '<button class="del" data-action="delete" aria-label="Delete">✕</button>' +
                '</li>';
        }).join("");
    }

    // Update remaining count
    var remaining = tasks.filter(function(t) { return !t.done; }).length;
    remainingEl.textContent = remaining;
}

Security note: Never insert raw user input into innerHTML. If a user typed <img src=x onerror="alert('hacked')">, it would run as code. The escapeHtml() function converts dangerous characters to safe entities.

6

Write the JavaScript — Events

The final piece: wire up the buttons. Notice the event delegation — we attach one listener to the <ul>, not one per task. This is essential because tasks are created and destroyed constantly.

JavaScript — Part 4: Events + Boot
// Add via button click
addBtn.addEventListener("click", function() {
    addTask(inputEl.value);
    inputEl.value = "";
    inputEl.focus();
});

// Add via Enter key
inputEl.addEventListener("keydown", function(e) {
    if (e.key === "Enter") {
        addTask(inputEl.value);
        inputEl.value = "";
    }
});

// EVENT DELEGATION — one listener on the parent UL
listEl.addEventListener("click", function(e) {
    var li = e.target.closest(".task");
    if (!li) return;
    var id = parseInt(li.dataset.id);
    if (e.target.dataset.action === "delete") {
        deleteTask(id);
    }
});

listEl.addEventListener("change", function(e) {
    if (e.target.dataset.action === "toggle") {
        var li = e.target.closest(".task");
        if (!li) return;
        toggleTask(parseInt(li.dataset.id));
    }
});

// Filter buttons
filterBtns.forEach(function(btn) {
    btn.addEventListener("click", function() {
        filterBtns.forEach(function(b) { b.classList.remove("active"); });
        btn.classList.add("active");
        filter = btn.dataset.filter;
        render();
    });
});

clearBtn.addEventListener("click", clearCompleted);

// BOOT — load saved tasks and render once
load();
render();

Save all files and open index.html. Add a few tasks, mark some done, refresh the page — your tasks should still be there. That's localStorage working.

7

Understand the Big Ideas

1. Data-driven UI vs. DOM-driven UI

Beginners often try to manipulate the DOM directly: "when the user clicks the checkbox, find the parent li, add the done class." This works for small apps but gets messy fast.

Instead, this app follows the data-driven pattern:

  1. Change the data (add/toggle/delete a task in the array)
  2. Save the data to localStorage
  3. Re-render the entire UI from the data

Yes, re-rendering the whole list every time is slightly less efficient than patching one element. But it's dramatically simpler — there's only one code path, and the UI is always in sync with the data.

2. Event delegation — why one listener beats many

If you attached a click listener to every delete button, you would need to attach a listener to each new task when created, remove listeners when tasks are deleted, and reattach listeners after every render.

Instead, one listener on the <ul> handles every task — past, present, and future. This is called event delegation, and it's the standard way to handle dynamic lists.

The key line is e.target.closest(".task"). It walks up the DOM from the clicked element until it finds the task container, so no matter what inner element was clicked, we can find its task.

3. localStorage — a tiny database in the browser

localStorage stores key-value pairs as strings. To save an array or object, you convert it to a string with JSON.stringify(), and convert it back with JSON.parse():

JavaScript
// Save
localStorage.setItem("key", JSON.stringify(myArray));

// Load
var data = JSON.parse(localStorage.getItem("key")) || [];

localStorage can hold roughly 5MB per domain, and it persists across browser sessions. It's not a full database — no querying, no indexing — but it's perfect for simple persistence like this.

8

Practice Challenges

🟢 Beginner

🟡 Intermediate

🔴 Advanced

Full Source Code

Common Mistakes & Fixes

❌ "Tasks disappear on refresh"

You forgot to call save() after changing the array, or you forgot to call load() on page startup. Both are needed.

❌ "Clicking delete deletes the wrong task"

You're using the array index instead of a unique id. Indexes shift when tasks are added/removed — always use stable IDs (Date.now() works well for simple apps).

❌ "The delete button doesn't work after adding new tasks"

You're attaching a listener to each button when the task is created. Switch to event delegation on the parent <ul> — one listener handles all tasks, forever.

❌ "Checkbox toggling does nothing"

Make sure you're listening to the change event, not click. Checkboxes fire both, but change is the correct semantic event for state changes.

❌ "Typing HTML in a task broke my page"

You forgot escapeHtml(). Never insert raw user input into innerHTML. Always escape it first.

What to Build Next