Patterns and Architecture in Vanilla JS
One-way data flow
State first, render second
The single highest-leverage architectural rule for vanilla apps: keep a single state object, and make DOM changes a consequence of state changes — never the other way around. Without it, the truth is smeared across DOM attributes and variables that drift apart.
// WRONG — the DOM IS the state; two sources of truth drift:
function addTodoWrong(text) {
const li = document.createElement("li");
li.textContent = text;
list.append(li); // truth lives in the DOM now…
count++; // …and also here. They WILL disagree.
}
// RIGHT — state is the source; render is a pure consequence:
const state = { todos: [] };
function addTodo(text) {
state.todos = [...state.todos, { text, done: false }]; // new array: no mutation
render(); // DOM rebuilt from state
}
function render() {
list.replaceChildren(...state.todos.map((todo) => {
const li = document.createElement("li");
li.textContent = todo.text;
return li;
}));
countEl.textContent = `${state.todos.length} items`; // derived — cannot disagree
}
This is the pattern every framework formalizes (React made it famous). Vanilla does not
need the framework — only the discipline. Demo practice/33_store_counter.html
shows the same counter written both ways.
The module pattern
Private state without classes
// An IIFE (or a module file) closes over state nothing else can touch:
const cart = (() => {
const items = []; // PRIVATE — no accessor exists
return {
add(item) { items.push(item); },
get total() { // read-only by construction
return items.reduce((sum, i) => sum + i.price, 0);
},
get size() { return items.length; },
};
})();
cart.add({ name: "tea", price: 4.5 });
console.log(cart.size, cart.total); // 1 4.5
console.log(cart.items); // undefined — the array is unreachable from outside
Pub/sub — decoupling with events
A tiny event bus in fifteen lines
// The publisher knows NOTHING about subscribers — that is the whole point.
function createBus() {
const handlers = new Map(); // event name → Set of callbacks
return {
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(handler);
return () => handlers.get(event).delete(handler); // returns the unsubscribe fn
},
emit(event, data) {
handlers.get(event)?.forEach((handler) => handler(data));
},
};
}
const bus = createBus();
const off = bus.on("cart:changed", (items) => console.log("badge:", items.length));
bus.emit("cart:changed", [{ price: 4 }]); // badge: 1
off(); // the badge stops listening — no leaks
This is the same delegation idea as lesson 13, lifted to application events: one emitter,
many subscribers, zero direct references between modules. The off return value
exists because forgotten subscriptions are the leak pattern from lesson 15.
Contraexample: bus for everything
A pub/sub for a linear call chain (A calls B calls C) hides the flow — a plain function call is easier to trace. Use the bus when modules genuinely should not know each other: cross-cutting concerns like auth state, analytics, or several views reacting to one change.
Composition over inheritance
Build capabilities by combining small factories
// Small, single-purpose factories — each returns a plain object:
const canLog = (self) => ({ log: () => console.log(self.name) });
const canSave = (self) => ({ save: () => localStorage.setItem(self.id, JSON.stringify(self)) });
// Compose what a type needs — no hierarchy, no fragile base class:
function makeDocument(name) {
const self = { id: crypto.randomUUID(), name };
return { ...self, ...canLog(self), ...canSave(self) };
}
const doc = makeDocument("report");
doc.log(); // report
doc.save();
Deep class hierarchies couple every subclass to decisions made above it; composition lets
each capability change alone. Classes still earn their place for genuine
instanceof semantics (lesson 09) — pick per problem, not per fashion.
Practice: one store, two components
The task
Open demo/practice/33_store_counter.html (Preview link on the Lab Examples
page): one counter written twice — DOM-as-state on the left, one-way data flow on the
right. Break the left one on purpose (edit the count directly in the console), then add a
second counter to the right side using the same render(). Then extend the
lesson's bus with a once(event, handler) method.
The checklist
- You keep one state object and render as a pure consequence of it.
- You use the module pattern whenever state must stay private.
- You reach for pub/sub only when modules should not know each other.
- You compose small factories before growing a class hierarchy.