Building an application
State, Rendering and Events
Hold the application in one object, draw the interface from it, and redraw when it changes. Covers diffing, keyed lists and the cursor trap.
Here is the shape almost every interface problem has. Something changes, part of the screen has to change with it, and the code that knows about the change is not the code that owns the screen. Handled locally, that becomes a function that updates a row and a badge and a count and the empty-state message, called from four places, with one of the four forgotten.
The alternative is a loop. Keep everything that can change in one object, write a function that draws the whole interface from that object, and call it after every change. The button handler stops touching the DOM and starts changing data.
Both approaches appear below, built as the same todo UI, because the trade-off is easier to see than to describe.
Direct mutation keeps a second copy of the truth in the DOM
const items = [];
function addItem(text) {
const li = document.createElement("li");
li.textContent = text;
li.addEventListener("click", () => {
li.classList.toggle("done");
});
list.append(li);
items.push({ text, li });
}
rows after three adds: 3
nodes the code still holds: 3
rows after a removal: 2 | tracked items: 3
items[1] is still in the document: false
The array and the document both describe the list, and nothing keeps them in step. Delete a row through any path that does not also splice the array and the two disagree from then on. Anything that asks "how many items are there" has two answers, and the wrong one is the one that was convenient to reach.
There is a second problem in that snippet, and it is the one from the previous lesson. Each row carries its own listener, so replacing a row silently drops the behaviour attached to it.
State plus a render function has one copy of the truth
const state = {
items: [
{ id: "1", text: "buy milk", done: false },
{ id: "2", text: "write lesson", done: true },
],
};
function render() {
const fragment = document.createDocumentFragment();
for (const item of state.items) {
const li = document.createElement("li");
li.dataset.id = item.id;
li.textContent = item.text;
if (item.done) li.classList.add("done");
fragment.append(li);
}
list.replaceChildren(fragment);
}
first render: 2 items
after add: 3 items
after filter: 1,3
first node reused across renders: false
Every action is now the same three steps: change state, call render, done. The handler for a toggle does not need to know about the counter in the header, because the header is drawn from the same object and gets redrawn with it. That property is the whole reason to write code this way, and it is why the pattern survives contact with a growing feature list.
The last line of the output is the cost. No node survives a render, so anything the browser or the user attached to a node, including listeners, focus and selection, is discarded along with it.
Why a full redraw is usually fast enough
Building a thousand list rows takes a few milliseconds. That number surprises people who assume DOM work is expensive, so it is worth measuring rather than arguing about:
$ node demos/re-render-cost.mjs
redraw everything: 50 re-renders of 1000 rows in 449 ms
keyed reconciliation: 50 re-renders of 1000 rows in 344 ms
Roughly nine milliseconds of work per redraw of a thousand rows, and a redraw happens once per user action. A click, a keystroke, a toggle: those arrive far slower than that. For the sizes most applications deal with, the loop is not the bottleneck, and the bugs it prevents cost more than the time it spends.
The reason to keep the measurements in your head is to know where the boundary is. A redraw becomes a problem when it happens at animation rate, when the list is in the tens of thousands of rows, or when the render function does something expensive per node, such as reading layout. If a page has 50 rows and a filter box, reaching for a diffing library to avoid rebuilding 50 elements is trading a real dependency for under a millisecond.
The diffing problem, and the cheapest honest answer
Rebuilding everything loses node identity. The fix that libraries spend their complexity on is diffing: compare the new tree to the old one and touch only what changed. Written from scratch, that is a hard problem with many special cases.
The cheap version gets most of the benefit for list-shaped interfaces. Key each item by a stable id, keep a map from id to existing element, and reuse the element when the id is still present. Update its text and classes in place, append the new ones, and remove the ones whose ids are gone.
function render() {
const existing = new Map([...list.children].map((li) => [li.dataset.id, li]));
const next = state.items.map((item) => {
let li = existing.get(item.id);
if (li) {
existing.delete(item.id);
} else {
li = document.createElement("li");
li.dataset.id = item.id;
}
li.textContent = item.text;
li.classList.toggle("done", item.done);
return li;
});
list.replaceChildren(...next);
for (const orphan of existing.values()) orphan.remove();
}
rows: 1,2,3,4
done class on row 1: true
row 1 is the same node: true
after removing id 2: 1,3,4
Two details make this work. The map is built from the live children at the start of the render, and each reused entry is deleted from it, so whatever remains at the end is exactly the set of removed items. And the second argument to classList.toggle is the state, not a flip, so the node's classes are derived from data rather than adjusted.
Reuse also means listeners can stay attached, which removes the re-render bug from the previous lesson. In the benchmark above, the keyed render was about 25 percent faster than rebuilding, because it skipped element creation for the rows that did not change. The larger win is correctness.
Element identity is not free, though. replaceChildren moves the reused nodes, and moving a node that has focus in a browser can drop that focus. If the interface has a focused control inside the region you are re-rendering, keep that control out of the reconciliation or restore focus explicitly after the render.
A full redraw loses the cursor, and it does not have to
Text inputs carry state the DOM holds and your state object does not: the caret, the selection, the scroll offset. Rebuild the subtree and the new input is a different node, so the caret lands wherever a fresh input puts it, which is the end of the value.
const text = "order of the";
let input = document.querySelector("#filter");
function renderFromState() {
const fresh = document.createElement("input");
fresh.id = "filter";
fresh.value = text;
input.replaceWith(fresh);
input = fresh;
input.focus();
}
input.focus();
input.setSelectionRange(10, 10);
console.log("typing position before the render:", input.selectionStart);
renderFromState();
console.log("typing position after the render: ", input.selectionStart, "(the caret is now at the end)");
// The same edit, applied to the node that is already there.
const kept = document.createElement("input");
kept.value = text;
document.body.append(kept);
kept.focus();
kept.setSelectionRange(10, 10);
console.log("typing position after an in-place update:", kept.selectionStart);
typing position before the render: 10
typing position after the render: 12 (the caret is now at the end)
typing position after an in-place update: 10
The value is correct, because the value came from state. The caret is not. A user editing the middle of "order of the phoenix" gets thrown to the end of the text on the next keystroke, and in the common version of this bug the render runs before the browser has applied the keystroke, so the character lands in the wrong place or disappears. That is the failure people describe as "the search box eats my typing".
Two ways out, and the choice depends on how much of the page the render owns.
The first is to render the list and leave the input alone. The input is a control the user is inside; the list is derived data. Draw the list, update the input's value only when it differs from state, and never replace the input node.
if (input.value !== state.filter) input.value = state.filter;
renderList();
The second is to render everything, then restore the transient state that matters. Record document.activeElement by id and the selection range before the render, and write both back after. That is what a framework does for you, and writing it by hand once makes the invisible machinery visible.
The same loss applies to scroll position
Replacing the children of a scrollable container resets its scroll offset in a browser, so a long list that redraws jumps back to the top. If the container scrolls, either reuse the container and its children rather than replacing them, or read scrollTop before the render and write it back after. jsdom stores and returns any value you assign to scrollTop, so it cannot show you this one.
Wire events so a redraw cannot duplicate or orphan them
Once a render can run at any time, listeners have two failure modes. A listener bound to a node that gets replaced is orphaned, and the feature it implemented stops working. A listener attached inside the render function on every call accumulates duplicates, and one click runs the handler three times.
The pattern that avoids both is one listener on a container that outlives the rows, dispatching on data- attributes, and a teardown that detaches everything in one call.
export function mount(root, { signal } = {}) {
function render() {
renderList(state.items);
}
const onClick = (event) => {
const item = event.target.closest("li[data-id]");
if (item) {
toggle(item.dataset.id);
render();
return;
}
const filter = event.target.closest("button[data-filter]");
if (filter) {
state.filter = filter.dataset.filter;
render();
}
};
root.addEventListener("click", onClick, { signal });
render();
return { destroy: () => root.removeEventListener("click", onClick) };
}
The container stays in the document for the life of the component, so the listener is never orphaned. The rows come and go underneath it, and because the handler reads the id off the element that was clicked, a fresh row works without registration. On teardown, one removeEventListener with the same function reference detaches it, and the optional signal gives callers the AbortController route instead.
This needs no access to the global document: it reaches it through root.ownerDocument. That matters for testing, because a test can build a page in jsdom and mount into it without arranging globals first. The next lesson does exactly that.
Write the same filterable todo UI twice, in two files, and measure both.
Version A, direct mutation. No state object. Adding appends a row, toggling flips a class on the row that was clicked, deleting removes that row. Keep any tracking you need in closures. Add a "clear done" button.
Version B, render from state. One object holding items and filter. Every action changes the object and calls render(). One delegated listener on the container handles toggle, delete and filter. The empty-state paragraph is rendered from state too.
Then write down, for each version: how many places in the code can remove a row from the interface, and which of them can leave the two sources of truth disagreeing. Version B should have one answer for both questions.
Finally, add 500 items to each and time 50 toggles. Report the two numbers and say whether the difference changes which version you would ship for a page with 500 rows.
Show the solution and the reasoning
Version A works, and that is the honest starting point. Every bug in it comes from the same place: a second copy of the data living in the DOM, plus listeners attached to nodes that can be removed.
// version A: direct mutation
const list = document.querySelector("#list");
const empty = document.querySelector("#empty");
const done = new Map();
function rowFor(item) {
const li = document.createElement("li");
li.dataset.id = item.id;
li.textContent = item.text;
const remove = document.createElement("button");
remove.dataset.action = "delete";
remove.textContent = "remove";
const toggle = document.createElement("button");
toggle.dataset.action = "toggle";
toggle.textContent = "toggle";
li.append(toggle, remove);
li.addEventListener("click", (event) => {
const action = event.target.dataset.action;
if (action === "delete") {
done.delete(item.id);
li.remove();
}
if (action === "toggle") {
const state = !done.get(item.id);
done.set(item.id, state);
li.classList.toggle("done", state);
}
empty.hidden = list.children.length > 0;
});
return li;
}
Count the removal paths in version A. Deleting a row removes the node and updates the map. "Clear done" iterates the children and removes some of them, and must update the map and the empty-state message in step. Any path added later has to remember both, and the map can drift from the document without anything failing loudly.
Version B removes the class of bug rather than the instance. State changes in one place, the render derives everything from it, and there is nothing to keep in step:
// version B: render from state
const state = { filter: "all", items: [] };
function visible() {
if (state.filter === "active") return state.items.filter((item) => !item.done);
if (state.filter === "done") return state.items.filter((item) => item.done);
return state.items;
}
function render() {
const existing = new Map([...list.children].map((li) => [li.dataset.id, li]));
const next = visible().map((item) => {
const li = existing.get(item.id) ?? document.createElement("li");
li.dataset.id = item.id;
li.textContent = item.text;
li.classList.toggle("done", item.done);
existing.delete(item.id);
return li;
});
list.replaceChildren(...next);
for (const orphan of existing.values()) orphan.remove();
empty.hidden = visible().length > 0;
count.textContent = String(state.items.filter((item) => !item.done).length);
}
The keyed map is doing real work here, not decoration. With plain replaceChildren and no reuse, the row under the pointer is destroyed on every toggle, which on a fast machine is invisible and on a slow one shows up as a click that lands on nothing. Reuse by id keeps the node alive across the redraw.
On the timing question, the measured difference between the two is smaller than most people expect, and it shrinks as the number of rows falls. With 500 rows and one toggle per action, version A will typically be marginally faster because it touches one node and version B touches the list. That is not an argument for version A. The comparison that matters is the count of places where the two sources of truth can disagree: version A has several and version B has none, and that difference does not show up in a benchmark of the happy path.
If a genuine performance problem appears, the lever is not the choice between these two versions. It is the number of nodes rebuilt per action, which keyed reuse addresses, followed by how often the render runs, which throttling addresses. Reaching for a virtual DOM first is guessing at the answer.
Progress
Mark this lesson done and it stays marked in this browser.