βœ…
CodingπŸŽ“ Ages 14-18Intermediate 15 min read

Building a To-Do List App

A step-by-step teen project: build a working to-do list app with HTML, CSS and JavaScript. Add tasks, store them in an array, render them to the page, mark them done and delete them, with full runnable code and a quiz.

Key takeaways

  • A to-do app combines HTML structure, CSS styling and JavaScript behaviour
  • Store tasks in an array, the single source of truth for your data
  • A render function rebuilds the list on the page from the array
  • Adding, completing and deleting all work by changing the array, then re-rendering
  • Event listeners connect button clicks to the functions that update the data

The project

Time to build something real. A to-do list app is the classic first project because it uses every core web skill at once: HTML for structure, CSS for looks, and JavaScript for behaviour. By the end you'll have an app where you can type a task, add it, tick it off, and delete it β€” all without reloading the page.

This pulls together ideas from earlier lessons. If anything feels shaky, revisit JavaScript and the DOM for selecting elements and JavaScript Events and Clicks for responding to clicks. We'll build the app in clear stages so you understand each piece.

The big idea: data drives the view

The most important concept in this whole lesson is this: keep your tasks in an array, and build the page from that array. The array is the "source of truth." Whenever you want to change anything β€” add, complete, delete β€” you change the array and then re-draw the list. You never edit the on-screen list directly.

This keeps things simple. There's one function, render(), whose only job is to make the screen match the array. Every action follows the same recipe:

  1. Change the tasks array.
  2. Call render().

That's it. Master this pattern and you've understood how modern web apps work.

Step 1: The HTML skeleton

We need an input box, an "Add" button, and an empty list that JavaScript will fill:

<h1>My To-Do List</h1>

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

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

The <ul id="taskList"> starts empty β€” JavaScript will create the <li> items inside it.

Step 2: The data and the render function

In JavaScript, we hold tasks as an array of small objects. Each task has a text and a done flag:

let tasks = [];   // starts empty

const taskList = document.getElementById("taskList");

function render() {
  taskList.innerHTML = "";   // clear the list first

  tasks.forEach(function (task, index) {
    const li = document.createElement("li");
    li.textContent = task.text;

    if (task.done) {
      li.classList.add("done");   // CSS will cross it out
    }

    taskList.appendChild(li);
  });
}

render() wipes the list, then loops over tasks and creates one <li> per task. We pass index along because we'll need it soon to know which task a button belongs to.

Step 3: Adding a task

When the Add button is clicked, read the input, push a new task object, clear the box, and re-render:

const input = document.getElementById("taskInput");
const addBtn = document.getElementById("addBtn");

function addTask() {
  const text = input.value.trim();
  if (text === "") return;          // ignore empty input

  tasks.push({ text: text, done: false });
  input.value = "";                 // clear the box
  render();                         // redraw the list
}

addBtn.addEventListener("click", addTask);

Notice the pattern: change the array (push), then render(). The trim() removes stray spaces, and the early return stops empty tasks being added.

Step 4: Completing and deleting

Now we want each task to be tickable and deletable. We add the buttons inside render() and use the index to act on the right task:

function render() {
  taskList.innerHTML = "";

  tasks.forEach(function (task, index) {
    const li = document.createElement("li");

    // The task text β€” click to toggle done
    const span = document.createElement("span");
    span.textContent = task.text;
    if (task.done) span.classList.add("done");
    span.addEventListener("click", function () {
      tasks[index].done = !tasks[index].done;  // flip true/false
      render();
    });

    // A delete button
    const del = document.createElement("button");
    del.textContent = "βœ•";
    del.addEventListener("click", function () {
      tasks.splice(index, 1);   // remove this one task
      render();
    });

    li.appendChild(span);
    li.appendChild(del);
    taskList.appendChild(li);
  });
}
  • Clicking the text flips done with ! (not), then re-renders so the CSS can cross it out.
  • Clicking βœ• uses splice(index, 1) to remove exactly that task, then re-renders.

Both follow the same recipe: change the array, call render().

A complete worked example

Here's the whole app in one file. Save it as todo.html, open it in a browser, and start adding tasks. Click a task to complete it; click βœ• to delete it.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>To-Do List</title>
  <style>
    body { font-family: sans-serif; max-width: 420px; margin: 30px auto; }
    .add-row { display: flex; gap: 8px; }
    #taskInput { flex: 1; padding: 8px; font-size: 16px; }
    button { padding: 8px 12px; cursor: pointer; }
    ul { list-style: none; padding: 0; }
    li {
      display: flex; justify-content: space-between; align-items: center;
      padding: 10px; border-bottom: 1px solid #ddd;
    }
    li span { cursor: pointer; flex: 1; }
    .done { text-decoration: line-through; color: #999; }
  </style>
</head>
<body>
  <h1>My To-Do List</h1>

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

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

  <script>
    let tasks = [];

    const input = document.getElementById("taskInput");
    const addBtn = document.getElementById("addBtn");
    const taskList = document.getElementById("taskList");

    function render() {
      taskList.innerHTML = "";

      tasks.forEach(function (task, index) {
        const li = document.createElement("li");

        const span = document.createElement("span");
        span.textContent = task.text;
        if (task.done) span.classList.add("done");
        span.addEventListener("click", function () {
          tasks[index].done = !tasks[index].done;
          render();
        });

        const del = document.createElement("button");
        del.textContent = "βœ•";
        del.addEventListener("click", function () {
          tasks.splice(index, 1);
          render();
        });

        li.appendChild(span);
        li.appendChild(del);
        taskList.appendChild(li);
      });
    }

    function addTask() {
      const text = input.value.trim();
      if (text === "") return;
      tasks.push({ text: text, done: false });
      input.value = "";
      render();
    }

    addBtn.addEventListener("click", addTask);

    // Bonus: let Enter add a task too
    input.addEventListener("keydown", function (event) {
      if (event.key === "Enter") addTask();
    });

    render();   // draw the (empty) list once at the start
  </script>
</body>
</html>

Trace one full action: you type "Walk the dog" and press Add. addTask pushes { text: "Walk the dog", done: false } onto tasks, clears the box, and calls render(). render() empties the list and rebuilds it from the array β€” now showing one task with its own click and delete handlers. Click the text and done flips to true, render runs again, and the CSS .done class crosses it out. Everything flows through the array.

Try it yourself

  1. Task counter. Add a line that shows "X of Y tasks done" by counting items in tasks where done is true. Update it inside render().
  2. Clear completed. Add a button that keeps only the unfinished tasks (tasks = tasks.filter(t => !t.done)) and re-renders.
  3. Edit a task. Add a second small button on each task that lets the user change its text (a simple prompt() is fine), then re-render.

Challenge: Make the list survive a refresh. After every change, save it with localStorage.setItem("tasks", JSON.stringify(tasks)), and when the page loads, read it back with JSON.parse(localStorage.getItem("tasks")) || []. Now your to-do list remembers everything between visits. If the array logic feels tricky, brush up with JavaScript Loops and Arrays.

Quick quiz

Test yourself and earn XP

Where should the list of tasks be stored?

What is the job of a render function?

After you push a new task to the array, what must you do?

How can you delete one task from the array?

Why give each task an 'done' property?

FAQ

Re-rendering from the array keeps your data and your screen perfectly in sync with very little code. For a small app this is simple and reliable. Larger apps optimise by updating only what changed, but the 'data drives the view' idea stays the same β€” it's exactly how frameworks like React think.

Save the array to the browser's localStorage whenever it changes (localStorage.setItem('tasks', JSON.stringify(tasks))) and load it back when the page opens. That stores the list on the user's device so it's still there next time.