Mini project: interactive task list
DOM and events18 min readLesson 3 of 4
3.1 The project
This project turns the DOM and event ideas into a small application: a task list that renders an array, adds tasks from a form, toggles a task between open and done, and removes a task. It keeps data in memory for now; module 6 adds localStorage so the list survives a refresh.
The finished page has a heading, a form with a text input and an add button, a list of tasks, and a message area for validation. The script owns one source of truth, an array of task objects, and renders the list from that array after every change. The page does not read its own DOM to discover state.
Keeping data and display separate is the main habit this project teaches. The render function receives data and writes DOM; event handlers change data and call render. A task toggled in the array appears on screen because render runs again, not because a CSS class was patched in one place.
3.2 Files and structure
Create three files in one folder: index.html, styles.css and app.js. Link the stylesheet in the head and the script at the end of the body with defer so the document exists before the script runs. The HTML provides the empty containers; JavaScript fills the list.
The HTML skeleton below is deliberately small. The heading and form are static, while the list and message area are empty elements the script controls. Keeping the static shell in HTML makes the page readable before JavaScript loads and gives the script stable hooks.
const skeleton = `
<section id="app">
<h1>Task list</h1>
<form id="task-form">
<input id="task-input" placeholder="New task" />
<button type="submit">Add</button>
</form>
<p id="message"></p>
<ul id="task-list"></ul>
</section>`;
console.log(skeleton.includes('id="task-form"'));
console.log(skeleton.includes('id="task-list"'));Output
true true
3.3 The data model
Each task is an object with an id, a title and a done flag. The id comes from a counter or crypto.randomUUID(). The title is the trimmed input. The done flag starts false and flips on toggle.
An array of these objects is the entire application state. A separate nextId variable or a helper that takes the maximum id keeps new ids unique. Rendering uses the array order, so adding pushes to the end and a future sort can order by done state or title.
Keep the model JSON-friendly. Plain strings, booleans and numbers survive a storage round trip or an API call. A task that stores a DOM element or a function in the object cannot be saved, and that limitation shows up again in module 6.
const tasks = [
{ id: 1, title: "Learn DOM", done: true },
{ id: 2, title: "Build a task list", done: false },
];
function renderTasks(list, tasks) {
list.innerHTML = "";
for (const task of tasks) {
const item = document.createElement("li");
item.className = task.done ? "done" : "open";
item.textContent = `${task.id}. ${task.title}`;
list.append(item);
}
}
const list = document.createElement("ul");
document.body.append(list);
renderTasks(list, tasks);
console.log(list.children.length);
console.log(JSON.stringify([...list.children].map((item) => item.className)));Output
2 ["done","open"]
3.4 Rendering from state
The render function starts by clearing the list, then builds one list item per task. Clearing and rebuilding is simple and fast for a beginner-sized list. The alternative, patching existing nodes, is faster on huge lists and much easier to get wrong.
Event delegation makes the rebuilt items interactive without attaching a listener to every new node. One click listener on the list reads the nearest item, finds the matching task by id, updates the data, and calls render again.
The empty state is part of rendering. When the array is empty, the list is empty and a message explains what to do next. An interface that shows nothing at all looks broken; a message makes the blank state intentional.
const list = document.createElement("ul");
const empty = document.createElement("p");
document.body.append(list, empty);
const tasks = [];
function render() {
list.innerHTML = "";
empty.textContent = tasks.length === 0 ? "No tasks yet" : "";
for (const task of tasks) {
const item = document.createElement("li");
item.textContent = task.title;
list.append(item);
}
}
render();
console.log(empty.textContent);
tasks.push({ id: 1, title: "Write project notes" });
render();
console.log(list.children.length);
console.log(empty.textContent === "");Output
No tasks yet 1 true
3.5 Toggling and delegation
The toggle handler listens on the list, finds the clicked item with closest, reads its data-id, and finds the task in the array. It flips the done flag, then calls render. The new class and the new text appear because the render function reads the updated data.
data-id must be a string in the DOM, so compare it as a string or convert it once. A lookup that returns undefined means the DOM and the data are out of sync, which is a bug to fix rather than a case to ignore quietly.
Double-toggling or double-clicking is harmless because the handler flips the boolean each time. Actions that must happen once, such as deleting a task, need a guard against a second event or a removed element.
const list = document.createElement("ul");
document.body.append(list);
const tasks = [{ id: 1, title: "Learn DOM", done: false }];
let clicked = null;
function render() {
list.innerHTML = "";
for (const task of tasks) {
const item = document.createElement("li");
item.dataset.id = String(task.id);
item.textContent = `${task.done ? "[x]" : "[ ]"} ${task.title}`;
list.append(item);
}
}
list.addEventListener("click", (event) => {
const item = event.target.closest("li");
if (!item) return;
const task = tasks.find((entry) => String(entry.id) === item.dataset.id);
task.done = !task.done;
render();
clicked = task.done;
});
render();
list.querySelector("li").click();
console.log(clicked);
console.log(list.querySelector("li").textContent);Output
true [x] Learn DOM
3.6 Adding tasks and validating input
The form submit handler stops the default action, trims the input, and rejects an empty or very short title. On success it pushes a task, clears the input, calls render, and moves focus back to the field. The focus move keeps the flow usable from the keyboard.
Validation rules belong in a small function so the same rule can be reused and tested. A title must be non-empty after trimming, and a maximum length protects the layout from a wall of text. The error message should name the problem and sit next to the field.
The add button is a submit button inside the form, so pressing Enter in the input submits the form too. That keyboard path is a feature, provided the form handler is the only code that adds tasks.
3.7 Styling and feedback
The script should not hard-code colours and spacing. Add or remove meaningful classes such as done and open, and let the stylesheet decide how each state looks. That separation lets a designer change the appearance without touching the JavaScript.
State changes need visible feedback. A toggled task gets a line-through style through its class, an added task appears at the end of the list, and an invalid title shows a message near the field. The user should never have to guess whether a click worked.
A disabled add button while a request is running needs the disabled attribute and a class; both communicate the state to the browser and to the eye. The script re-enables it in a finally block so an error cannot leave the form stuck.
3.8 Common mistakes
- Reading state from the DOM instead of the data array.
- Attaching a listener to each item and leaking listeners as the list grows.
- Forgetting preventDefault, so the form reloads the page.
- Comparing
dataset.idwith a number and never matching. - Rebuilding the list without preserving focus or scroll position.
- Treating an empty string as a valid title after trimming.
Exam
Project questions ask for a function or for the result of an interaction. Trace the data: the handler changes the array, render reads the array, and the DOM follows. If the DOM and the data disagree, the data is the source of truth.
- Write the HTML skeleton with a form, a list and a message area.
- Write a render function that builds one list item per task.
- Add a delegated click listener that toggles a task.
- Validate the form input and test an empty title.
