JavaScript Design Patterns
Practical implementations of the most useful JavaScript design patterns — Module, Singleton, Observer, Factory, and Command — with real-world use cases.
Design patterns are proven solutions to recurring design problems. They’re a shared vocabulary — saying “use the Observer pattern here” communicates more precisely than describing the wiring from scratch. This guide shows each pattern with a production-style implementation, not a toy example. The goal is to recognize the problem a pattern solves so you reach for it when it fits, not to apply patterns for their own sake.
Module Pattern
The Module pattern uses a closure to create private state — variables and functions that are inaccessible from outside the module, with only a deliberate public API exposed. This solves the problem of global scope pollution and prevents external code from depending on internal implementation details. The IIFE form was essential before ES modules; it’s still useful in environments without bundlers.
// IIFE-based module — creates a self-contained namespace with private state
const ShoppingCart = (() => {
// Private state — inaccessible from outside the IIFE
const items = [];
let discountCode = null;
// Private helper — not exposed, only used internally
function calculateSubtotal() {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Public API — only what's returned is accessible to callers
return {
addItem(product, quantity = 1) {
const existing = items.find((i) => i.id === product.id);
if (existing) {
existing.quantity += quantity;
} else {
items.push({ ...product, quantity });
}
},
removeItem(productId) {
const index = items.findIndex((i) => i.id === productId);
if (index !== -1) items.splice(index, 1);
},
applyDiscount(code) {
discountCode = code;
},
getTotal() {
const subtotal = calculateSubtotal();
return discountCode === "SAVE10" ? subtotal * 0.9 : subtotal;
},
getItemCount() {
return items.reduce((sum, i) => sum + i.quantity, 0);
},
};
})();
ShoppingCart.addItem({ id: 1, name: "Book", price: 29.99 }, 2);
ShoppingCart.applyDiscount("SAVE10");
console.log(ShoppingCart.getTotal()); // 53.982
With ES modules (modern approach), file scope provides the same encapsulation without the IIFE wrapper:
// cart.js — file scope is the module boundary
const items = []; // private — not exported, not accessible outside this file
export function addItem(product, qty = 1) { /* ... */ }
export function getTotal() { /* ... */ }
Singleton
The Singleton pattern ensures only one instance of a class exists across the entire application. This is useful for shared services like configuration, loggers, database connection pools, and caches — resources where having multiple independent instances would cause inconsistency or waste.
class AppConfig {
#data = {};
constructor(initialData = {}) {
this.#data = initialData;
}
get(key) {
return this.#data[key];
}
set(key, value) {
this.#data[key] = value;
}
merge(newData) {
Object.assign(this.#data, newData);
}
}
// The Singleton wrapper — a module-level variable holds the single instance
let instance = null;
export function getConfig(initialData) {
if (!instance) {
instance = new AppConfig(initialData); // create only on first call
}
return instance;
}
// Usage across multiple files — always returns the same instance
import { getConfig } from "./config.js";
const config = getConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
config.set("debug", true);
// Somewhere else in the codebase:
const config2 = getConfig(); // same instance — still has debug: true
When to avoid: Singletons make unit testing hard because they carry state between tests. Prefer passing the instance as a dependency (dependency injection) rather than importing the singleton directly in every module that needs it.
Observer / EventEmitter
The Observer pattern decouples publishers from subscribers. One part of your system emits events; other parts react to them — without either side needing a direct reference to the other. This is the foundation of event-driven architecture: the data store doesn’t know or care which UI components are listening, and components can subscribe or unsubscribe without modifying the store.
class EventEmitter {
#listeners = new Map();
on(event, handler) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(handler);
// Return an unsubscribe function — cleaner than a separate off() call
return () => this.off(event, handler);
}
once(event, handler) {
// Wrap the handler so it removes itself after the first invocation
const wrapper = (...args) => {
handler(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
off(event, handler) {
this.#listeners.get(event)?.delete(handler);
}
emit(event, ...args) {
this.#listeners.get(event)?.forEach((handler) => handler(...args));
}
}
// Real usage: a data store that notifies UI components when data changes
class UserStore extends EventEmitter {
#users = [];
async loadUsers() {
this.emit("loading"); // UI can show a spinner
try {
const res = await fetch("/api/users");
this.#users = await res.json();
this.emit("loaded", this.#users); // UI renders the list
} catch (err) {
this.emit("error", err); // UI shows an error message
}
}
getUsers() {
return [...this.#users]; // return a copy to prevent external mutation
}
}
const store = new UserStore();
// Subscribe — returns an unsubscribe function
const unsubscribe = store.on("loaded", (users) => {
renderUserList(users);
});
store.on("error", (err) => {
showErrorBanner(err.message);
});
store.loadUsers();
// Later — clean up to prevent memory leaks (e.g., on component unmount)
unsubscribe();
Factory
Factories centralize object creation logic, hiding the choice of which concrete class to instantiate from callers. This is valuable when the exact type to create depends on runtime data (a format string, a config value, a user preference), or when construction involves complex setup that callers shouldn’t need to know about. Adding a new type never requires touching existing code — just register it.
// Without Factory — callers must import and know about every concrete class
import { PNGExporter, JPEGExporter, WebPExporter } from "./exporters.js";
const exporter = new PNGExporter(options); // caller is tightly coupled to concrete class
// With Factory — callers specify what they want, not how to build it
class ExporterFactory {
static #registry = new Map();
static register(format, ExporterClass) {
this.#registry.set(format.toLowerCase(), ExporterClass);
}
static create(format, options = {}) {
const ExporterClass = this.#registry.get(format.toLowerCase());
if (!ExporterClass) {
throw new Error(
`Unknown export format: "${format}". Registered: ${[...this.#registry.keys()].join(", ")}`
);
}
return new ExporterClass(options);
}
}
// Register formats — each format module registers itself (plugin-style)
ExporterFactory.register("png", PNGExporter);
ExporterFactory.register("jpeg", JPEGExporter);
ExporterFactory.register("webp", WebPExporter);
// Usage — no import of concrete classes at the call site
async function exportImage(canvas, format, quality) {
const exporter = ExporterFactory.create(format, { quality });
const blob = await exporter.export(canvas);
return blob;
}
// Adding a new format never touches existing code — just register it
ExporterFactory.register("avif", AVIFExporter);
Command Pattern
The Command pattern encapsulates an operation as an object. Each command has an execute method and, optionally, an undo method. This decoupling gives you undo/redo, operation queuing, logging, and retry logic essentially for free — features that would be very hard to bolt on after the fact without a command abstraction.
// Each command encapsulates one reversible operation
class Command {
execute() { throw new Error("Not implemented"); }
undo() { throw new Error("Not implemented"); }
}
class InsertTextCommand extends Command {
constructor(editor, position, text) {
super();
this.editor = editor;
this.position = position;
this.text = text;
}
execute() {
this.editor.insert(this.position, this.text);
}
undo() {
// Reverse the insert by deleting the same range
this.editor.delete(this.position, this.text.length);
}
}
class DeleteTextCommand extends Command {
constructor(editor, position, length) {
super();
this.editor = editor;
this.position = position;
this.length = length;
this.deletedText = ""; // captured during execute so undo can restore it
}
execute() {
this.deletedText = this.editor.read(this.position, this.length);
this.editor.delete(this.position, this.length);
}
undo() {
this.editor.insert(this.position, this.deletedText);
}
}
// CommandHistory manages undo/redo stacks — works with any Command subclass
class CommandHistory {
#undoStack = [];
#redoStack = [];
execute(command) {
command.execute();
this.#undoStack.push(command);
this.#redoStack = []; // a new action invalidates the redo history
}
undo() {
const command = this.#undoStack.pop();
if (!command) return;
command.undo();
this.#redoStack.push(command);
}
redo() {
const command = this.#redoStack.pop();
if (!command) return;
command.execute();
this.#undoStack.push(command);
}
canUndo() { return this.#undoStack.length > 0; }
canRedo() { return this.#redoStack.length > 0; }
}
// Usage
const history = new CommandHistory();
history.execute(new InsertTextCommand(editor, 0, "Hello, World!"));
history.execute(new InsertTextCommand(editor, 13, " How are you?"));
history.undo(); // removes " How are you?"
history.redo(); // puts it back
Choosing the Right Pattern
| Problem | Pattern |
|---|---|
| Private state, clean public API | Module |
| One shared instance across the app | Singleton |
| Components react to events without coupling | Observer |
| Defer or vary object construction | Factory |
| Operations need undo/redo or queuing | Command |
Patterns are tools, not rules. Start with the simplest code that works. Reach for a pattern when a known problem appears — not before.