Practice: a to-do list with no framework
Six lessons of pieces. This one assembles them into something you can actually use: a to-do list that adds, completes, deletes, filters and survives a reload, in about 120 lines with no framework.
Build it in order and run it after every step. Type it rather than pasting it — the typos you make and fix are where the learning is.
The idea that holds it together
One rule, and it decides every design question below:
An array is the truth. The page is a picture of it.
Every change updates the array, saves it, and redraws from it. Nothing is ever read back out of the DOM to find out what is going on. That sounds like extra work and is the opposite — it is the reason you never end up with a counter that disagrees with the list.
This is also, precisely, what React does. You are doing it by hand once.
Step 1: the markup
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Things to do</title>
</head>
<body>
<h1>Things to do</h1>
<form id="new-task" novalidate>
<input type="text" id="title" placeholder="What needs doing?" required maxlength="80" />
<button type="submit">Add</button>
<p class="error" id="title-error"></p>
</form>
<div id="filters">
<button type="button" data-filter="all" class="is-active">All</button>
<button type="button" data-filter="active">Active</button>
<button type="button" data-filter="done">Done</button>
</div>
<ul id="tasks"></ul>
<p id="count"></p>
<script src="todo.js" defer></script>
</body>
</html>
The list is empty. JavaScript fills it — that is what "the page is a picture of the array" means in practice.
Note type="button" on the filters. Inside a form they would submit it; these
are outside one, but the habit is worth keeping. And defer, from module 1.
Step 2: storage that cannot crash the page
const STORAGE_KEY = 'tasks';
function save(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
function load(key, fallback) {
try {
const text = localStorage.getItem(key);
if (text === null) return fallback;
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
The Array.isArray check is the part people leave out. Storage might hold
something written by an older version of your own code, or edited by hand. You
are not just guarding against corrupt text; you are guarding against valid JSON
of the wrong shape. Without it, {} loads and everything downstream fails on
.filter.
Step 3: the state
let tasks = load(STORAGE_KEY, []);
let filter = 'all';
const form = document.querySelector('#new-task');
const titleInput = document.querySelector('#title');
const titleError = document.querySelector('#title-error');
const list = document.querySelector('#tasks');
const count = document.querySelector('#count');
const filters = document.querySelector('#filters');
Two pieces of state and six element references, looked up once. let for
the two things that change, const for everything else.
A task looks like { id, title, done }.
Step 4: render
function visibleTasks() {
if (filter === 'active') return tasks.filter((task) => !task.done);
if (filter === 'done') return tasks.filter((task) => task.done);
return tasks;
}
function render() {
list.textContent = '';
for (const task of visibleTasks()) {
const item = document.createElement('li');
item.className = 'task';
item.dataset.id = task.id;
if (task.done) item.classList.add('is-done');
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = task.done;
toggle.dataset.action = 'toggle';
const label = document.createElement('span');
label.className = 'title';
label.textContent = task.title;
const remove = document.createElement('button');
remove.type = 'button';
remove.dataset.action = 'delete';
remove.textContent = 'Delete';
item.append(toggle, label, remove);
list.append(item);
}
const left = tasks.filter((task) => !task.done).length;
count.textContent = `${left} of ${tasks.length} left`;
}
Four decisions worth naming.
list.textContent = '' empties the list — shorter and faster than
innerHTML = '', and it cannot parse anything.
label.textContent = task.title, never innerHTML. The title came from a
person. This one line is what stands between your page and the cross-site
scripting hole from the selecting lesson, and step 9 proves it.
data-action on each control, so one delegated listener can tell them apart.
The count comes from tasks, not from visibleTasks() — "2 of 5 left" must
mean all five, not the ones currently on screen. Reading it from
list.children.length would have been wrong the moment a filter was applied.
That is the "array is the truth" rule earning its keep.
Step 5: changing the state
function addTask(title) {
tasks = [...tasks, { id: crypto.randomUUID(), title, done: false }];
save(STORAGE_KEY, tasks);
render();
}
function toggleTask(id) {
tasks = tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task,
);
save(STORAGE_KEY, tasks);
render();
}
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
save(STORAGE_KEY, tasks);
render();
}
The same three lines every time: change, save, render. No function touches
the page directly; they all go through render. That is why the display can
never drift out of step with the data.
All three build a new array rather than mutating — module 4's
[...list, item], map with a spread, and filter. crypto.randomUUID() is
built into every current browser and gives a unique id with no counter to
maintain.
Step 6: adding
form.addEventListener('submit', (event) => {
event.preventDefault();
titleError.textContent = '';
const title = titleInput.value.trim();
if (title === '') {
titleError.textContent = 'Type something to do first.';
titleInput.focus();
return;
}
addTask(title);
form.reset();
titleInput.focus();
});
titleInput.addEventListener('input', () => {
titleError.textContent = '';
});
preventDefault first, or the page reloads. .trim() before the check, so
three spaces is not a task. focus() after adding, so you can type the next one
without reaching for the mouse — a small thing that makes the difference between
a demo and something you would use.
The input listener clears the error as soon as they start fixing it.
Step 7: one listener for the whole list
list.addEventListener('click', (event) => {
const control = event.target.closest('[data-action]');
if (!control) return;
const row = control.closest('.task');
if (!row) return;
if (control.dataset.action === 'toggle') toggleTask(row.dataset.id);
if (control.dataset.action === 'delete') deleteTask(row.dataset.id);
});
One listener, on the container, for every row that exists now or later —
which matters here because render destroys and rebuilds every row on every
change. Per-row listeners would have to be reattached constantly and would leak.
Two closest calls: one to find which control was clicked, one to find which
row it belongs to. Both guarded, so a click on the list's padding does nothing.
Step 8: filters
filters.addEventListener('click', (event) => {
const button = event.target.closest('[data-filter]');
if (!button) return;
filter = button.dataset.filter;
for (const other of filters.querySelectorAll('[data-filter]')) {
other.classList.toggle('is-active', other === button);
}
render();
});
classList.toggle(name, condition) with a second argument adds or removes to
match the condition — exactly right for "only this one is active".
The filter changes a variable and re-renders. It does not hide rows with CSS, because then the DOM and the state would disagree.
Step 9: prove it is safe
Type this as a task title:
<img src=x onerror="alert(1)">
It appears as that exact text and nothing happens. No alert, and no img
element in the DOM — check in the Elements panel.
Now change label.textContent = task.title to label.innerHTML = task.title,
reload, and add it again. The alert fires.
Change it back. That is a one-word difference between a safe page and a compromised one, and it is worth having done once with your own hands.
Step 10: prove it recovers
Open the Application panel, find the tasks key, and replace its value with
{not json at all. Reload.
The page loads, empty, and the form still works. No blank screen, no error in
the console. That is what the try/catch in load bought you, and it is the
difference between a bug one user hits and a bug that makes your page look
broken to them forever — because a crash on load means they cannot even clear it.
What is deliberately missing
Being honest about a teaching example.
No editing — an obvious next feature, and the exercise below.
It re-renders everything on every change. For a few dozen tasks this is instant. For two thousand rows it would be visibly slow, and you would need to update only what changed. That is the problem React exists to solve, and you have now felt the shape of it.
Focus is lost on re-render. Tick a checkbox and the element the browser was focused on has been destroyed and replaced. Keyboard users notice immediately. Fixing it by hand is fiddly; frameworks handle it for you.
Nothing is shared. It is in one browser on one device. Module 8 is where data starts coming from somewhere else.
Check your work
The list starts empty in the HTML and is filled by render. The array is the
truth; the page is a picture of it.
load needs the Array.isArray check, not just try/catch — valid JSON
of the wrong shape breaks everything downstream just as thoroughly as corrupt
text.
The count reads from tasks, not visibleTasks(). With the Done filter
active, "1 of 2 left" still describes everything, which is what a person expects.
Every change does the same three things: update the array, save, render. Nothing writes to the page directly, so the display cannot drift.
All three updates build a new array — spread, map with a spread, filter —
rather than mutating.
One delegated listener survives render destroying every row. Per-row
listeners would need reattaching on every change.
textContent renders <img src=x onerror="alert(1)"> as literal text, with
no img element created and no code run. innerHTML in the same place executes
it. That one word is the whole difference.
Corrupt the stored JSON and the page still loads, empty and usable, because
load falls back.
Submitting three spaces shows the error and adds nothing, because of
.trim().
Practice
- Build it step by step, running after each one. Do not paste.
- Break it deliberately once. Remove
event.preventDefault()and watch the page reload; removerender()fromaddTaskand watch the count go stale while the array is correct. Put both back. - Add a task, reload, and confirm it is still there. Then clear storage from devtools and reload again.
- Run the XSS test in step 9 both ways. Confirm
textContentis safe andinnerHTMLis not. - Run the corruption test in step 10. Confirm the page survives.
- Add a "Clear completed" button that removes every done task at once. One array operation, then save and render.
- Make the empty state friendly: when there are no tasks at all, show "Nothing to do." — and make sure it does not appear merely because a filter matched nothing. Those are two different empty states, and telling them apart is the point.
- Show the count as "All done." when nothing is left.
- Add editing. Double-clicking a title turns it into an input; Enter saves, Escape cancels, and an empty title deletes the task. Keep the rule — change the array, save, render. This is the hardest thing in the module and the most worthwhile.
- Harder. Add drag-free reordering with Move up and Move down buttons. Then notice that reordering is the one operation where re-rendering everything feels wrong, and write a sentence about why — that instinct is what the next framework you learn is built around.
That is module seven, and the first thing in this course you could show someone. You can find elements, change them safely, respond to a person, handle a form, and keep data between visits — and you have done it with one array, one render function and three listeners.
The habit to carry forward is the one at the top: state is the truth, the page is a picture of it. Every front-end framework you meet is an implementation of that sentence.
Next module: asynchronous JavaScript. Your to-do list knows only what is in this browser. Module 8 is how a page gets data from somewhere else — and why that turns out to be the hardest idea in the language.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship