Section 5 · Writing Real Programs
Building Pocket
22 min3 min readCode playground
Lesson at a glance
Assemble Pocket into a complete browser app with one state object, pure logic, a single render function, delegated events, filtering and saved data, and check it against a definition of done.
- 1The shape: one state, one way to change it
- 2Render derives everything
- 3Handlers translate events into updates
- 4A definition of done
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
You have all the pieces: money helpers, validation, rendering, events, a form, storage, modules and classes. This lesson puts them together into the finished Pocket, and, more importantly, shows the shape that holds a small app together so it stays easy to change.
The shape: one state, one way to change it
Every interactive app has data that changes. In Pocket that is the list of expenses and the category filter the user picked. Put all of it in one object, the state:
const state = {
expenses: loadExpenses(),
filter: "all",
};Then make one function the only way to change it. It applies the change, saves, and re-renders:
function update(changes) {
Object.assign(state, changes);
saveExpenses(state.expenses);
render();
}Object.assign(state, changes) copies the properties of changes onto state, so update({ filter: "food" }) changes only the filter. Every event handler now ends the same way: work out the new values, then call update. You never need to remember to save or redraw, because it is impossible not to.
Render derives everything
render reads the state and draws the whole interface. Anything that can be computed from the state is computed here, not stored: the visible list depends on the filter, and the total depends on the visible list.
function render() {
const visible =
state.filter === "all"
? state.expenses
: state.expenses.filter((e) => e.category === state.filter);
list.replaceChildren();
if (visible.length === 0) {
const empty = document.createElement("li");
empty.className = "empty";
empty.textContent = "No expenses yet. Add your first one above.";
list.append(empty);
}
for (const expense of visible) {
list.append(renderItem(expense));
}
totalEl.textContent = `Total: ${formatMoney(totalCents(visible))}`;
}
function renderItem(expense) {
const li = document.createElement("li");
li.dataset.id = expense.id;
const text = document.createElement("span");
text.textContent = `${expense.label}: ${formatMoney(expense.amount)} (${expense.category})`;
const button = document.createElement("button");
button.type = "button";
button.className = "delete";
button.textContent = "Delete";
button.setAttribute("aria-label", `Delete ${expense.label}`);
li.append(text, button);
return li;
}Because render always starts from the state, the filter, the list and the total can never contradict each other. Switching the filter to “food” does not hide some <li> elements and recalculate a number; it simply draws a different picture of the same state.
Two details worth copying. The empty state turns a blank list into guidance. And the delete button’s aria-label tells screen-reader users which expense it deletes, since ten buttons all called “Delete” are useless to someone who cannot see the row.
Handlers translate events into updates
With the state, update and render in place, each feature is a few lines:
form.addEventListener("submit", (event) => {
event.preventDefault();
const result = readExpenseForm(form); // { values } or { errors }
if (result.errors) {
showErrors(form, result.errors);
return;
}
update({ expenses: addExpense(state.expenses, { id: nextId(), ...result.values }) });
form.reset();
form.elements.label.focus();
});
list.addEventListener("click", (event) => {
const button = event.target.closest(".delete");
if (!button) return;
const id = button.closest("li").dataset.id;
update({ expenses: state.expenses.filter((e) => e.id !== id) });
});
filterSelect.addEventListener("change", () => {
update({ filter: filterSelect.value });
});
render();readExpenseForm and showErrors are the form code from section 4, moved into small named functions: one reads, trims, converts and validates, returning either clean values or an errors object; the other writes the messages and sets aria-invalid. Giving each a name turns the submit handler into a summary you can read in five seconds.
Notice what the handlers do not contain: no DOM building, no saving, no totals. They read the event, call pure functions such as addExpense from section 3, and hand the result to update. The rules live in expenses.js, where you can test them without a browser; the handlers are glue.
A definition of done
“Adding an expense works” is where testing starts, not where it ends. Before calling Pocket finished, run through this list by hand:
- Empty: with no data, the page explains what to do.
- Bad input: an empty label, an amount of
abc,0or-5each show a clear message and add nothing. - Reload: everything you added is still there, and the filter starts at “All”.
- Keyboard only: you can tab to every field and button, submit with Enter, and delete with Enter or Space.
- Odd data: a label like
<b>hi</b>shows as text, and a very long label does not break the layout. - Console: no errors or warnings while you do all of the above.
Every item comes from a lesson in this course, and each catches a real class of bugs that users would otherwise find for you.
The exercise below hands you a nearly finished Pocket with the filter and delete still to wire up. After that, one short lesson on where to go from here.
Key takeaways
- Keep all changing data in one place and change it through one function that saves and re-renders, so the page, the data and storage never disagree.
- Derive everything you can, such as the visible list and the totals, from the state at render time instead of storing it separately.
- Pure functions hold the rules; handlers stay short and only translate events into state changes.
- An app is done when it handles the empty state, bad input, a reload and keyboard-only use, not just the happy path.