JavaScript Practice #6 Build a Small App — Todo App
This is the last post of the JavaScript track. Tying together the tools covered so far — DOM, event delegation, FormData, local storage, a small store — you’ll build a Todo app from scratch without a library.
What we’ll build — requirements #
- Type a task to add it
- Click an item to toggle done/undone
- Delete with a delete button
- Filter — all / active / done
- Data persists across reloads (localStorage)
- Syncs with other tabs
Zero libraries. Two files (HTML + JS) without a build tool are enough.
Step 1 — HTML skeleton #
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Todo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<h1>Todo</h1>
<form id="add-form">
<input
name="text"
placeholder="Enter a task"
required
autofocus
>
<button type="submit">Add</button>
</form>
<div class="filters">
<button data-filter="all" class="active">All</button>
<button data-filter="active">Active</button>
<button data-filter="done">Done</button>
</div>
<ul id="todo-list"></ul>
<p class="stats" id="stats"></p>
</main>
<script type="module" src="main.js"></script>
</body>
</html>Two key points.
<form required>— leveraging built-in validation from #4 Working with Forms<script type="module">— Basics #7 Modules
Step 2 — Building the store #
Separate the data shape from state management.
// State shape
// {
// todos: [{ id: string, text: string, done: boolean, createdAt: number }],
// filter: 'all' | 'active' | 'done',
// }
const KEY = 'todo-app';
function load() {
try {
const raw = localStorage.getItem(KEY);
if (raw === null) return { todos: [], filter: 'all' };
return JSON.parse(raw);
} catch {
return { todos: [], filter: 'all' };
}
}
function save(state) {
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch (err) {
console.warn('save failed:', err);
}
}
export function createStore() {
let state = load();
const listeners = new Set();
function notify() {
listeners.forEach((fn) => fn(state));
}
function set(updater) {
state = typeof updater === 'function' ? updater(state) : updater;
save(state);
notify();
}
// Sync changes from other tabs
window.addEventListener('storage', (e) => {
if (e.key !== KEY || e.newValue === null) return;
try {
state = JSON.parse(e.newValue);
notify();
} catch {}
});
return {
get: () => state,
set,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}The exact pattern built in #5. Persistence + multi-tab sync, all in one module.
Step 3 — Action functions #
Rather than calling set directly, defining actions with clear names keeps the code organized.
import { createStore } from './store.js';
export const store = createStore();
export const actions = {
add(text) {
if (!text.trim()) return;
store.set((s) => ({
...s,
todos: [
...s.todos,
{
id: crypto.randomUUID(),
text: text.trim(),
done: false,
createdAt: Date.now(),
},
],
}));
},
toggle(id) {
store.set((s) => ({
...s,
todos: s.todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
),
}));
},
remove(id) {
store.set((s) => ({
...s,
todos: s.todos.filter((t) => t.id !== id),
}));
},
setFilter(filter) {
store.set((s) => ({ ...s, filter }));
},
};crypto.randomUUID() is built-in, so you can generate IDs without a library (supported in modern browsers and Node).
Step 4 — The render function #
Build a one-way flow that paints state onto the screen.
const list = document.querySelector('#todo-list');
const stats = document.querySelector('#stats');
const filterBtns = document.querySelectorAll('[data-filter]');
function filterTodos(todos, filter) {
if (filter === 'active') return todos.filter((t) => !t.done);
if (filter === 'done') return todos.filter((t) => t.done);
return todos;
}
export function render(state) {
// List
const visible = filterTodos(state.todos, state.filter);
list.innerHTML = visible.map((t) => `
<li class="todo ${t.done ? 'done' : ''}" data-id="${t.id}">
<input type="checkbox" ${t.done ? 'checked' : ''}>
<span class="text">${escapeHtml(t.text)}</span>
<button class="remove" aria-label="Delete">✕</button>
</li>
`).join('');
// Stats
const total = state.todos.length;
const done = state.todos.filter((t) => t.done).length;
stats.textContent = total === 0
? 'No items'
: `${done} / ${total} done`;
// Filter button active state
filterBtns.forEach((btn) => {
btn.classList.toggle('active', btn.dataset.filter === state.filter);
});
}
// XSS prevention — required when putting user input into innerHTML
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}escapeHtml is the key. Always run user input through a basic escape — guarding against the XSS pitfall from Practice #1, where input could contain something like <script>.
Step 5 — Event handlers (event delegation) #
Instead of attaching a handler to every list item, delegate to one parent.
import { store, actions } from './actions.js';
import { render } from './ui.js';
const form = document.querySelector('#add-form');
const list = document.querySelector('#todo-list');
const filters = document.querySelector('.filters');
// Add — form submit
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!form.checkValidity()) return form.reportValidity();
const formData = new FormData(form);
actions.add(formData.get('text'));
form.reset();
form.elements.text.focus();
});
// Toggle / delete — delegated
list.addEventListener('click', (e) => {
const li = e.target.closest('.todo');
if (!li) return;
const id = li.dataset.id;
if (e.target.matches('.remove')) {
actions.remove(id);
} else if (e.target.matches('input[type="checkbox"]')) {
actions.toggle(id);
} else if (e.target.matches('.text')) {
actions.toggle(id);
}
});
// Filters — delegated
filters.addEventListener('click', (e) => {
const btn = e.target.closest('[data-filter]');
if (!btn) return;
actions.setFilter(btn.dataset.filter);
});
// Render on state change
store.subscribe(render);
// Initial paint
render(store.get());The core flow:
- User action → call
actions.xxx() actionsmutates state viastore.setstorecalls listeners →render(state)runs- DOM updates
That’s one-way data flow. The mental model React uses, applied at small scale.
Step 6 — A handful of CSS #
Not directly tied to functionality, but added for styling.
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
background: #f5f5f5;
}
.app {
max-width: 480px;
margin: 40px auto;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
input[name="text"] {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
}
.filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filters button {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
}
.filters button.active {
background: #333;
color: white;
border-color: #333;
}
ul {
list-style: none;
padding: 0;
margin: 0 0 16px;
}
.todo {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.todo .text {
flex: 1;
cursor: pointer;
}
.todo.done .text {
text-decoration: line-through;
color: #999;
}
.remove {
background: none;
border: none;
color: #c33;
cursor: pointer;
}
.stats {
text-align: center;
color: #666;
font-size: 14px;
}Confirming it works #
Open index.html in your browser — you can type to add, click to toggle, click X to delete, the filters work, and reload preserves everything. Open the same page in another tab and changes from one side reflect immediately on the other.
Zero libraries. The code length is around 200 lines.
Places to extend #
Things that fit nicely on top of this skeleton:
- Editing — double-click an item’s text to enter edit mode
- Drag-and-drop sorting — HTML5 draggable or SortableJS
- Search — filter by text
- Categories/tags — extend the data shape
- External sync — server storage via fetch (#3 fetch and Async UI)
- Service Worker — offline support
Every tool covered in this series is usable for each of these.
Where a library starts to be needed #
This skeleton goes surprisingly far. Almost any small-to-medium app fits comfortably. You start considering a library (React/Vue, etc.) at these points.
- When components nest deeply and direct DOM manipulation gets complex
- When you need routing / SSR / code splitting
- When a large team is touching the same code (standardized component interfaces ease collaboration)
- When you want a richer toolchain — build / TypeScript / testing
The learning flow of this track points exactly to that next step — JavaScript → TypeScript → React. By then you’ll already have answers to “why was this tool needed.”
Wrapping up #
What this post covered:
- A Todo app from start to finish with HTML + JS only
- One-way data flow — actions → store → render
- The event delegation pattern — one handler on a parent
- localStorage persistence + cross-tab sync
- XSS escape handling
- Leveraging built-in form validation
- Zero libraries, no build tools
Wrapping up the JavaScript track #
The full flow — 4 series, 27 posts:
- Basics (7 posts) — from environment to modules (#1)
- Intermediate (7 posts) — classes, async, destructuring, fetch (Intermediate #1)
- Advanced (7 posts) — closures, this, prototypes, the event loop, memory (Advanced #1)
- Practice (6 posts) — DOM, events, fetch UI, forms, storage, a small app (this series)
With this much in hand, you can build everyday web interactions freely in JavaScript, and it sets you up for the TypeScript track or React track on top of it. Starting the next track with answers to “why is this tool needed” makes the learning curve much smoother.