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.
| Pattern | Problem it solves | Idiomatic JavaScript |
|---|---|---|
| Module | hide internals, expose an interface | a module file, or a closure returning an object |
| Factory | create objects without exposing construction | a function that returns an object |
| Singleton | exactly one shared instance | an exported const, because modules evaluate once |
| Observer | notify interested parties about a change | an emitter with on, off, emit |
| Pub/Sub | decouple publisher and subscriber entirely | a shared bus keyed by topic strings |
| Strategy | swap one step of an algorithm | pass a function, or a lookup object of functions |
| Command | represent an action as data, undo it later | objects with do and undo, on a stack |
| State machine | make illegal transitions impossible | a transition table plus a current state |
| Adapter | make a foreign interface fit yours | a wrapper function or object |
| Dependency injection | make collaborators substitutable for tests | pass 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); // -> trueA 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); // nothingThirty 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')); // -> 22A 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 trueA 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 stubAn adapter isolates a foreign shape. Injection makes it swappable in tests.
- Inject what is hard to control Time, randomness, storage, network, the DOM. Everything else can stay hard-coded.
- Default the real thing Production code should not have to pass anything. Tests override just what they need.
- 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
emititerate 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
Setis 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,isEmptyandisSuccessbooleans 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();? userInputcan name an inherited property such asconstructorortoString, returning a function you never registered — A plain object inherits fromObject.prototype, sostrategies['constructor']is a real function andstrategies['toString']is another. Guard withObject.hasOwn, build the table withObject.create(null), or use aMap, 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
oncewrapper 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
undois not the exact inverse ofdo, 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
thisproblems. A class gives prototypes,instanceofand inheritance. - Modules evaluate once, so exported instances are singletons. Export the factory too, for tests.
- An emitter must dispatch over a snapshot, and
onshould 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.