Patterns and Architecture in Vanilla JS

Frameworks sell architecture; vanilla JavaScript can have the same discipline. This lesson builds the small patterns real apps are made of.

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.