Design Patterns

Mental model: A design pattern is a name for a shape you keep drawing anyway, and in JavaScript most of the classic shapes collapse into a closure, a function argument, or an object of functions.

Level: advanced · about 24 minutes

The classic pattern catalogue was written for a language with no first-class functions, so half of it is a workaround for something JavaScript gives you free. That does not make the catalogue useless: the names are how teams talk about structure. What matters is knowing which shape solves which problem, and which patterns are one line here and a whole class hierarchy elsewhere.

PatternProblem it solvesIdiomatic JavaScript
Modulehide internals, expose an interfacea module file, or a closure returning an object
Factorycreate objects without exposing constructiona function that returns an object
Singletonexactly one shared instancean exported const, because modules evaluate once
Observernotify interested parties about a changean emitter with on, off, emit
Pub/Subdecouple publisher and subscriber entirelya shared bus keyed by topic strings
Strategyswap one step of an algorithmpass a function, or a lookup object of functions
Commandrepresent an action as data, undo it laterobjects with do and undo, on a stack
State machinemake illegal transitions impossiblea transition table plus a current state
Adaptermake a foreign interface fit yoursa wrapper function or object
Dependency injectionmake collaborators substitutable for testspass them in as parameters

Module and factory

function createCart() {
  const items = [];                      // private, unreachable from outside

  return {
    add(name, price) {
      items.push({ name, price });
      return this;
    },
    remove(name) {
      const i = items.findIndex((item) => item.name === name);
      if (i > -1) items.splice(i, 1);
      return this;
    },
    get total() {
      return items.reduce((sum, item) => sum + item.price, 0);
    },
    get size() {
      return items.length;
    },
    toJSON() {
      return { items: items.map((i) => ({ ...i })), total: this.total };
    },
  };
}

const cart = createCart().add('tea', 250).add('mug', 700);
console.log(cart.total, cart.size);      // -> 950 2
console.log(Object.keys(cart).includes('items'));   // -> false, the array is private
console.log(JSON.stringify(cart.toJSON()).length > 0);   // -> true

A factory returning a closed-over interface. No new, no this, nothing to bind.

Class

class Cart {
  #items = [];
  add(name, price) {
    this.#items.push({ name, price });
    return this;
  }
  get total() {
    return this.#items
      .reduce((s, i) => s + i.price, 0);
  }
}
new Cart();

Factory

function createCart() {
  const items = [];
  return {
    add(name, price) {
      items.push({ name, price });
      return this;
    },
    get total() {
      return items
        .reduce((s, i) => s + i.price, 0);
    },
  };
}
createCart();

Classes win on memory (methods live once on the prototype), on instanceof checks, and on inheritance. Factories win on privacy without syntax, on returning different shapes from the same call, and on never having a this binding problem. Both are fine. Mixing both styles in one module is what confuses readers.

Observer: build an EventEmitter

function createEmitter() {
  const listeners = new Map();   // event -> Set of functions

  return {
    on(event, fn) {
      if (!listeners.has(event)) listeners.set(event, new Set());
      listeners.get(event).add(fn);
      return () => this.off(event, fn);        // unsubscribe handle
    },
    off(event, fn) {
      listeners.get(event)?.delete(fn);
      return this;
    },
    once(event, fn) {
      const wrapper = (...args) => {
        this.off(event, wrapper);
        fn(...args);
      };
      return this.on(event, wrapper);
    },
    emit(event, ...args) {
      const set = listeners.get(event);
      if (!set) return 0;
      for (const fn of [...set]) fn(...args);   // snapshot: safe to unsubscribe during emit
      return set.size;
    },
  };
}

const bus = createEmitter();
const stop = bus.on('tick', (n) => console.log('listener A', n));
bus.once('tick', (n) => console.log('listener B (once)', n));

bus.emit('tick', 1);   // -> listener A 1, listener B (once) 1
bus.emit('tick', 2);   // -> listener A 2
stop();
bus.emit('tick', 3);   // nothing

Thirty lines that cover ninety percent of what a library emitter does.

const listeners = new Set();
const on = (fn) => listeners.add(fn);
const emit = () => { for (const fn of [...listeners]) fn(); };

const log = [];
on(() => { log.push('first'); on(() => log.push('added during emit')); });
on(() => log.push('second'));

emit();
console.log(log.join(','));

The [...listeners] snapshot is taken before the loop starts, so a listener added during dispatch is not in it and does not run this round. It will run on the next emit. Without the snapshot, iterating a Set while adding to it would visit the new entry too, and a listener that re-subscribes itself would loop forever. Snapshot-then-dispatch is the standard fix and the reason every real emitter does it.

Observer
the subject knows its observers. Direct, typed, easy to trace
Pub/Sub
a bus in the middle. Total decoupling, and no way to find who listens
rule of thumb
observer inside a module, bus only across module boundaries

Strategy: the pattern that is just a parameter

const shipping = {
  standard: (weight) => 3 + weight * 0.5,
  express: (weight) => 8 + weight * 0.9,
  pickup: () => 0,
};

function quote(weight, method = 'standard') {
  const strategy = shipping[method] ?? shipping.standard;
  return Number(strategy(weight).toFixed(2));
}

console.log(quote(4), quote(4, 'express'), quote(4, 'pickup'));   // -> 5 11.6 0

// Adding a strategy is adding a key, not editing a conditional.
shipping.drone = (weight) => 20 + weight * 2;
console.log(quote(1, 'drone'));   // -> 22

A lookup object of functions replaces a switch that would grow forever.

Command: actions as data

function createHistory() {
  const done = [];
  const undone = [];

  return {
    execute(command) {
      command.do();
      done.push(command);
      undone.length = 0;              // a new action invalidates the redo branch
      return this;
    },
    undo() {
      const command = done.pop();
      if (!command) return false;
      command.undo();
      undone.push(command);
      return true;
    },
    redo() {
      const command = undone.pop();
      if (!command) return false;
      command.do();
      done.push(command);
      return true;
    },
    get labels() {
      return done.map((c) => c.label);
    },
  };
}

const doc = { text: '' };
const append = (s) => ({
  label: `append "${s}"`,
  do: () => { doc.text += s; },
  undo: () => { doc.text = doc.text.slice(0, -s.length); },
});

const history = createHistory();
history.execute(append('Hello')).execute(append(' world'));
console.log(doc.text);        // -> Hello world
history.undo();
console.log(doc.text);        // -> Hello
history.redo();
console.log(doc.text, history.labels);   // -> Hello world [ 'append "Hello"', 'append " world"' ]

Once an action is an object, undo, redo, logging and replay all become list operations.

State machine: making bad states unreachable

const machine = {
  initial: 'idle',
  states: {
    idle: { FETCH: 'loading' },
    loading: { RESOLVE: 'success', REJECT: 'failure', ABORT: 'idle' },
    success: { FETCH: 'loading' },
    failure: { FETCH: 'loading', RESET: 'idle' },
  },
};

function createMachine({ initial, states }) {
  let current = initial;
  return {
    get state() {
      return current;
    },
    can(event) {
      return Boolean(states[current]?.[event]);
    },
    send(event) {
      const next = states[current]?.[event];
      if (!next) return false;      // illegal transitions are ignored, not crashes
      current = next;
      return true;
    },
  };
}

const req = createMachine(machine);
console.log(req.state, req.send('RESOLVE'), req.state);   // -> idle false idle
console.log(req.send('FETCH'), req.state);                // -> true loading
console.log(req.can('RESOLVE'), req.can('FETCH'));        // -> true false
req.send('REJECT');
console.log(req.state, req.can('RESET'));                 // -> failure true

A transition table is the difference between five booleans and one state.

Adapter and dependency injection

// Two "APIs" with different shapes
const legacyApi = { fetchUser: (id, cb) => cb(null, { user_name: 'ada', user_id: id }) };
const modernApi = { getUser: async (id) => ({ name: 'ada', id }) };

// Adapters make them both look like { load(id): Promise<{ id, name }> }
const fromLegacy = (api) => ({
  load: (id) =>
    new Promise((resolve, reject) =>
      api.fetchUser(id, (err, row) => (err ? reject(err) : resolve({ id: row.user_id, name: row.user_name })))
    ),
});
const fromModern = (api) => ({ load: (id) => api.getUser(id) });

// The consumer takes its dependency as a parameter, so it never knows which is which
async function showUser(source, id) {
  const user = await source.load(id);
  return `#${user.id} ${user.name}`;
}

Promise.all([showUser(fromLegacy(legacyApi), 1), showUser(fromModern(modernApi), 2)]).then((out) =>
  console.log(out)
);
// -> [ '#1 ada', '#2 ada' ]

// And a test needs no network at all:
showUser({ load: async () => ({ id: 9, name: 'stub' }) }, 9).then((s) => console.log(s));   // -> #9 stub

An adapter isolates a foreign shape. Injection makes it swappable in tests.

  1. Inject what is hard to control Time, randomness, storage, network, the DOM. Everything else can stay hard-coded.
  2. Default the real thing Production code should not have to pass anything. Tests override just what they need.
  3. Stop at the boundary Injecting every collaborator turns the app into a wiring diagram. Inject at the edges of your system, not between every pair of functions.

MVC-lite: putting it together

   user event
       |
       v
  +----------+   action    +--------------+   state    +--------+
  |   view   | ----------> |    store     | ---------> |  view  |
  | (render) |             | (reduce+emit)|            | rerender|
  +----------+             +--------------+            +--------+
       ^                          |
       +--- subscribe -------------+
function createStore(reducer, initial) {
  let state = initial;
  const listeners = new Set();
  return {
    get state() {
      return state;
    },
    dispatch(action) {
      const next = reducer(state, action);
      if (next === state) return state;      // no change, no notification
      state = next;
      for (const fn of [...listeners]) fn(state, action);
      return state;
    },
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

const store = createStore(
  (state, action) => (action.type === 'inc' ? { count: state.count + 1 } : state),
  { count: 0 }
);

const stop = store.subscribe((s) => console.log('render', s.count));
store.dispatch({ type: 'inc' });      // -> render 1
store.dispatch({ type: 'noop' });     // nothing, the reducer returned the same object
stop();
store.dispatch({ type: 'inc' });      // no render, but state moved on
console.log(store.state);             // -> { count: 2 }

A store in twenty lines: reducer for change, emitter for notification.

Try it yourself

A bus with wildcards

function createBus() {
  const topics = new Map();
  return {
    subscribe(topic, fn) {
      if (!topics.has(topic)) topics.set(topic, new Set());
      topics.get(topic).add(fn);
      return () => topics.get(topic)?.delete(fn);
    },
    publish(topic, payload) {
      let delivered = 0;
      for (const fn of [...(topics.get(topic) ?? [])]) {
        fn(payload, topic);
        delivered += 1;
      }
      return delivered;
    },
    get topics() {
      return [...topics.keys()];
    },
  };
}

const bus = createBus();
const off = bus.subscribe('order:created', (p) => console.log('email service got', p.id));
bus.subscribe('order:created', (p) => console.log('analytics got', p.id));

console.log('delivered to', bus.publish('order:created', { id: 'A1' }), 'subscribers');
off();
console.log('delivered to', bus.publish('order:created', { id: 'A2' }), 'subscribers');
console.log(bus.topics);

Add support for a * topic that receives every message with its topic name. Then add once and prove it unsubscribes itself.

A state machine with entry actions

function createMachine(config) {
  let current = config.initial;
  const log = [];
  const enter = (state, event) => {
    log.push(`${state} (via ${event})`);
    config.states[state]?.onEnter?.(state);
  };
  return {
    get state() { return current; },
    get log() { return log.slice(); },
    send(event) {
      const next = config.states[current]?.on?.[event];
      if (!next) return false;
      current = next;
      enter(next, event);
      return true;
    },
  };
}

const player = createMachine({
  initial: 'stopped',
  states: {
    stopped: { on: { PLAY: 'playing' } },
    playing: { on: { PAUSE: 'paused', STOP: 'stopped' }, onEnter: () => console.log('audio started') },
    paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
  },
});

console.log(player.send('PAUSE'), player.state);   // -> false stopped
player.send('PLAY');
player.send('PAUSE');
player.send('PLAY');
console.log(player.state, player.log);

Add an onExit hook and a guard function that can veto a transition. Then draw the diagram of legal transitions from the table alone.

Exercises

Build an EventEmitter

Write createEmitter() returning an object with on(event, fn), off(event, fn), once(event, fn), emit(event, ...args) and listenerCount(event). on and once return an unsubscribe function. emit returns how many listeners it called, and passes every argument through. A listener that unsubscribes (or subscribes) during an emit must not change that same dispatch. The same function added twice for the same event counts once.

Undo with the command pattern

Write createHistory() with execute(command), undo(), redo(), canUndo, canRedo and labels. A command is { label, do, undo }. execute runs do, pushes onto the undo stack and clears the redo stack. undo and redo move one command between the stacks and return true, or return false when there is nothing to move. labels lists the labels currently on the undo stack, oldest first. A command missing do or undo throws a TypeError and is not recorded.

Check yourself

Why does emit iterate a copy of the listener collection?
so a listener that unsubscribes or subscribes during dispatch cannot corrupt the current round — Mutating a collection while iterating it gives you skipped entries (removal) or an entry added mid-flight running immediately (addition), and a listener that re-subscribes itself can loop forever. Taking the snapshot first makes one dispatch mean one fixed list. A Set is perfectly iterable, and copying is not faster.
Which statement about singletons in JavaScript is correct?
A module body evaluates once per program, so an exported instance is already a singleton — The module system caches by resolved specifier, so every importer sees the same evaluated bindings. export const store = createStore() is a singleton with no ceremony. The trap is testing: shared state persists between tests, so export the factory alongside the instance and let tests build their own.
You have isLoading, isError, isEmpty and isSuccess booleans in a component. What does converting them to a single state remove?
the twelve combinations that should never happen, such as loading and error at the same time — Four booleans span sixteen combinations while the feature has four legal states, so twelve are bugs waiting to be reported ("the spinner stays after the error"). One state variable makes those unrepresentable, and the transition table documents which changes are allowed. That is the entire value of the state machine pattern.
What is the risk in const fn = strategies[userInput]; fn();?
userInput can name an inherited property such as constructor or toString, returning a function you never registered — A plain object inherits from Object.prototype, so strategies['constructor'] is a real function and strategies['toString'] is another. Guard with Object.hasOwn, build the table with Object.create(null), or use a Map, which has no prototype keys at all. Lesson 15.11 shows what an attacker does with this.

Common mistakes

  • Subscribing without ever unsubscribing, which keeps the listener and its whole closure alive.
  • Iterating the live listener collection inside emit, so removal during dispatch skips a listener.
  • A once wrapper that calls the listener before removing itself, which re-entrant code can fire twice.
  • Exporting only a singleton instance, so tests share state and pass or fail depending on order.
  • A strategy lookup on a plain object keyed by user input.
  • Command objects whose undo is not the exact inverse of do, which silently corrupts history.
  • Forgetting to clear the redo stack after a new command.
  • A pub/sub bus used inside a single module, which makes the data flow untraceable for no benefit.
  • Dependency injection everywhere, until the app is more wiring than behaviour.

Takeaways

  • Most classic patterns reduce to a closure, a function parameter, or an object of functions.
  • A factory gives privacy and no this problems. A class gives prototypes, instanceof and inheritance.
  • Modules evaluate once, so exported instances are singletons. Export the factory too, for tests.
  • An emitter must dispatch over a snapshot, and on should return the unsubscribe function.
  • Strategy is a lookup of functions. Guard the lookup if the key comes from outside.
  • Command objects turn actions into data, which is what makes undo, redo and replay possible.
  • A transition table makes illegal states unrepresentable instead of merely untested.
  • Inject what is hard to control (time, network, storage) and hard-code the rest.