A to-do list is the classic project, because it uses nearly everything: arrays of objects, rendering, event delegation and (later) storage.
<style>
.todo { font-family: system-ui, sans-serif; max-width: 320px; }
form { display: flex; gap: 6px; }
input[type="text"] { flex: 1; padding: 8px; font: inherit; }
button { font: inherit; padding: 6px 12px; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 12px 0; }
li { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #eee; }
li.done span { text-decoration: line-through; color: #888; }
li span { flex: 1; }
.count { color: #555; font-size: 14px; }
</style>
<div class="todo">
<form id="form">
<label for="task" class="sr" style="position:absolute;left:-9999px">New task</label>
<input type="text" id="task" placeholder="What needs doing?" autocomplete="off">
<button>Add</button>
</form>
<ul id="list"></ul>
<p class="count" id="count" role="status"></p>
</div>
<script>
let todos = [
{ id: 1, text: "Learn JavaScript", done: true },
{ id: 2, text: "Build a to-do list", done: false }
];
let nextId = 3;
const list = document.querySelector("#list");
const countEl = document.querySelector("#count");
function render() {
list.replaceChildren(...todos.map(todo => {
const li = document.createElement("li");
li.dataset.id = todo.id;
li.classList.toggle("done", todo.done);
const box = document.createElement("input");
box.type = "checkbox";
box.checked = todo.done;
box.setAttribute("aria-label", "Done: " + todo.text);
const text = document.createElement("span");
text.textContent = todo.text;
const del = document.createElement("button");
del.textContent = "Delete";
del.dataset.action = "delete";
li.append(box, text, del);
return li;
}));
const left = todos.filter(t => !t.done).length;
countEl.textContent = `${left} task${left === 1 ? "" : "s"} left`;
}
document.querySelector("#form").addEventListener("submit", event => {
event.preventDefault();
const input = document.querySelector("#task");
const text = input.value.trim();
if (!text) return;
todos = [...todos, { id: nextId++, text, done: false }];
input.value = "";
render();
});
// one listener for the whole list (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 === "delete") {
todos = todos.filter(t => t.id !== id);
render();
}
});
list.addEventListener("change", event => {
const li = event.target.closest("li");
const id = Number(li.dataset.id);
todos = todos.map(t => (t.id === id ? { ...t, done: event.target.checked } : t));
render();
});
render();
</script>
The ideas used
- State is an array of objects (
todos). The page is drawn from it byrender(). - Immutable updates: adding uses spread, deleting uses
filter, toggling usesmap(Spread, filter). - Event delegation: one
clickand onechangelistener on the list handle every row, including rows added later (Event Delegation). textContentkeeps user text safe (Safe Output).data-idlinks each row to its task (Data Attributes).- Accessibility: a label for the input and each checkbox, a status message for the counter.
Next steps
- Save to
localStorage: after every change,localStorage.setItem("todos", JSON.stringify(todos)), and load it at the start (JSON with localStorage). - Add filters: All, Active, Done.
- Let users edit a task by double-clicking it.
- Add Clear completed.
Exercise: clear completed
Add a “Clear completed” button that removes every finished task.
Show a possible solution
<button id="clear">Clear completed</button>
document.querySelector("#clear").addEventListener("click", () => {
todos = todos.filter(t => !t.done);
render();
});