JavaScript Design Patterns Every Developer Should Know
Learn the most useful JavaScript design patterns with practical examples including Singleton, Observer, Factory, Strategy, and more for cleaner code.
What you'll learn
- ✓When and why to use common design patterns in JavaScript
- ✓How to implement Singleton, Observer, Factory, and Strategy patterns
- ✓Modern ES2024+ approaches to classic patterns
- ✓How to pick the right pattern for your problem
Prerequisites
- •Solid JavaScript fundamentals including classes and closures
Design patterns are reusable solutions to common software design problems. They are not copy-paste code templates but rather blueprints that you adapt to your specific situation. In JavaScript, many classic patterns from the Gang of Four book map naturally to the language’s dynamic features like closures, first-class functions, and prototypal inheritance.
Singleton Pattern
A Singleton ensures that a class has only one instance and provides a global point of access to it. In JavaScript, ES modules are already singletons by default since the module system caches the exported value after the first import. But when you need explicit control, here is the pattern:
class Database {
static #instance = null;
#connection = null;
constructor(connectionString) {
if (Database.#instance) {
return Database.#instance;
}
this.#connection = connectionString;
Database.#instance = this;
}
query(sql) {
console.log(`Executing on ${this.#connection}: ${sql}`);
}
static getInstance(connectionString) {
if (!Database.#instance) {
Database.#instance = new Database(connectionString);
}
return Database.#instance;
}
}
const db1 = Database.getInstance("postgres://localhost/mydb");
const db2 = Database.getInstance("postgres://localhost/other");
console.log(db1 === db2); // true
The simpler module-based Singleton is often better in practice:
// db.js
const connection = createConnection(process.env.DATABASE_URL);
export function query(sql, params) {
return connection.execute(sql, params);
}
Every file that imports from db.js gets the same connection object. No class ceremony needed.
When to use: shared resources like database connections, configuration stores, or logging services.
Observer Pattern
The Observer pattern defines a one-to-many relationship where one object (the subject) notifies multiple dependents (observers) about state changes. This is the backbone of event-driven architectures.
class EventEmitter {
#listeners = new Map();
on(event, callback) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(callback);
return () => this.off(event, callback);
}
off(event, callback) {
this.#listeners.get(event)?.delete(callback);
}
emit(event, ...args) {
this.#listeners.get(event)?.forEach(cb => cb(...args));
}
once(event, callback) {
const unsubscribe = this.on(event, (...args) => {
unsubscribe();
callback(...args);
});
return unsubscribe;
}
}
const store = new EventEmitter();
const unsubscribe = store.on("userLogin", user => {
console.log(`${user.name} logged in`);
});
store.emit("userLogin", { name: "Alice" });
unsubscribe();
The on method returns an unsubscribe function, which prevents memory leaks from forgotten listeners. Using a Set for listeners prevents duplicate registrations.
When to use: decoupling components that need to react to state changes, custom event systems, pub/sub messaging.
Factory Pattern
The Factory pattern encapsulates object creation logic, letting you create objects without specifying the exact class. This is especially useful when the creation logic is complex or when you need to return different types based on input.
class Notification {
constructor(message) {
this.message = message;
this.timestamp = Date.now();
}
send() {
throw new Error("send() must be implemented");
}
}
class EmailNotification extends Notification {
constructor(message, to) {
super(message);
this.to = to;
}
send() {
console.log(`Email to ${this.to}: ${this.message}`);
}
}
class SMSNotification extends Notification {
constructor(message, phone) {
super(message);
this.phone = phone;
}
send() {
console.log(`SMS to ${this.phone}: ${this.message}`);
}
}
class PushNotification extends Notification {
constructor(message, deviceId) {
super(message);
this.deviceId = deviceId;
}
send() {
console.log(`Push to ${this.deviceId}: ${this.message}`);
}
}
function createNotification(type, message, recipient) {
const factories = {
email: () => new EmailNotification(message, recipient),
sms: () => new SMSNotification(message, recipient),
push: () => new PushNotification(message, recipient),
};
const factory = factories[type];
if (!factory) {
throw new Error(`Unknown notification type: ${type}`);
}
return factory();
}
const notification = createNotification("email", "Hello!", "user@example.com");
notification.send();
When to use: when object creation involves logic that should not live in the calling code, or when the type of object depends on runtime configuration.
Strategy Pattern
The Strategy pattern defines a family of interchangeable algorithms, encapsulates each one, and makes them swappable at runtime. In JavaScript, first-class functions make this pattern lightweight:
const sortStrategies = {
price: (a, b) => a.price - b.price,
name: (a, b) => a.name.localeCompare(b.name),
rating: (a, b) => b.rating - a.rating,
newest: (a, b) => new Date(b.createdAt) - new Date(a.createdAt),
};
function sortProducts(products, strategyName) {
const strategy = sortStrategies[strategyName];
if (!strategy) {
throw new Error(`Unknown sort strategy: ${strategyName}`);
}
return [...products].sort(strategy);
}
const products = [
{ name: "Laptop", price: 999, rating: 4.5, createdAt: "2026-01-15" },
{ name: "Mouse", price: 29, rating: 4.8, createdAt: "2026-03-20" },
{ name: "Keyboard", price: 79, rating: 4.2, createdAt: "2026-02-10" },
];
const byPrice = sortProducts(products, "price");
const byRating = sortProducts(products, "rating");
A more sophisticated example using validators:
const validators = {
required: value => value != null && value !== "",
email: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
minLength: min => value => value.length >= min,
range: (min, max) => value => value >= min && value <= max,
};
function validateField(value, rules) {
const errors = [];
for (const rule of rules) {
const validate = typeof rule === "string" ? validators[rule] : rule;
if (!validate(value)) {
errors.push(`Failed validation: ${rule}`);
}
}
return errors;
}
const errors = validateField("", ["required", "email"]);
When to use: when you have multiple ways to accomplish the same task and want to switch between them without changing the calling code.
Proxy Pattern
JavaScript has a built-in Proxy object that makes this pattern first-class. A Proxy wraps an object and intercepts operations on it:
function createValidatedObject(schema) {
const data = {};
return new Proxy(data, {
set(target, property, value) {
const validator = schema[property];
if (validator && !validator(value)) {
throw new TypeError(
`Invalid value for ${property}: ${JSON.stringify(value)}`
);
}
target[property] = value;
return true;
},
get(target, property) {
if (!(property in target)) {
throw new ReferenceError(`Property ${property} does not exist`);
}
return target[property];
},
});
}
const user = createValidatedObject({
age: v => typeof v === "number" && v >= 0 && v <= 150,
email: v => typeof v === "string" && v.includes("@"),
name: v => typeof v === "string" && v.length > 0,
});
user.name = "Alice";
user.age = 30;
user.email = "alice@example.com";
When to use: validation, access control, logging, lazy loading, and caching.
Decorator Pattern
Decorators wrap an object or function to extend its behavior without modifying its source:
function withLogging(fn, label) {
return function (...args) {
console.log(`[${label}] Called with:`, args);
const start = performance.now();
const result = fn.apply(this, args);
const duration = performance.now() - start;
console.log(`[${label}] Returned in ${duration.toFixed(2)}ms:`, result);
return result;
};
}
function withRetry(fn, maxRetries = 3) {
return async function (...args) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn.apply(this, args);
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 100;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
};
}
function withCache(fn, ttl = 60000) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) {
return cached.value;
}
const result = fn.apply(this, args);
cache.set(key, { value: result, time: Date.now() });
return result;
};
}
const fetchUser = withRetry(
withLogging(
async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
},
"fetchUser"
)
);
These function decorators compose naturally. Each wrapper adds a concern without the inner function knowing about it.
When to use: cross-cutting concerns like logging, caching, retry logic, authentication checks.
Module Pattern
The Module pattern uses closures to create private state with a public API. While ES modules have largely replaced this, it is still useful for creating encapsulated units within a module:
function createCounter(initial = 0) {
let count = initial;
const listeners = new Set();
function notify() {
listeners.forEach(fn => fn(count));
}
return {
increment() {
count++;
notify();
return count;
},
decrement() {
count--;
notify();
return count;
},
getCount() {
return count;
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
const counter = createCounter(10);
const unsub = counter.subscribe(val => console.log("Count:", val));
counter.increment();
counter.increment();
unsub();
The count variable is truly private. No external code can access or modify it except through the returned methods.
Command Pattern
The Command pattern turns actions into objects, enabling undo/redo, queuing, and logging:
class CommandManager {
#history = [];
#undone = [];
execute(command) {
command.execute();
this.#history.push(command);
this.#undone = [];
}
undo() {
const command = this.#history.pop();
if (command) {
command.undo();
this.#undone.push(command);
}
}
redo() {
const command = this.#undone.pop();
if (command) {
command.execute();
this.#history.push(command);
}
}
}
class MoveCommand {
constructor(element, x, y) {
this.element = element;
this.x = x;
this.y = y;
this.previousX = 0;
this.previousY = 0;
}
execute() {
this.previousX = this.element.x;
this.previousY = this.element.y;
this.element.x = this.x;
this.element.y = this.y;
}
undo() {
this.element.x = this.previousX;
this.element.y = this.previousY;
}
}
const manager = new CommandManager();
const box = { x: 0, y: 0 };
manager.execute(new MoveCommand(box, 100, 50));
console.log(box); // { x: 100, y: 50 }
manager.undo();
console.log(box); // { x: 0, y: 0 }
manager.redo();
console.log(box); // { x: 100, y: 50 }
When to use: undo/redo systems, task queues, macro recording, transaction-based operations.
Choosing the Right Pattern
Picking a pattern starts with identifying the problem:
- Object creation is complex? Factory
- Need exactly one instance? Singleton (or just a module export)
- Components need to react to changes? Observer
- Multiple algorithms for the same task? Strategy
- Want to add behavior without modifying source? Decorator
- Need undo/redo or action queuing? Command
- Need to intercept or control object access? Proxy
- Want private state with a public API? Module
Avoid forcing patterns where they are not needed. A simple function often beats a class hierarchy. JavaScript’s dynamic nature means many patterns are lighter than their Java or C++ equivalents.
Wrapping Up
Design patterns give your team a shared vocabulary and proven solutions to recurring problems. In JavaScript, closures and first-class functions simplify most patterns dramatically compared to classical OOP languages. Start with the patterns that solve problems you actually have rather than applying them preemptively. As your codebase grows, these patterns help keep complexity manageable and code maintainable.
Related articles
- JavaScript The Decorator Pattern in JavaScript
Learn the Decorator pattern in JavaScript -- wrap functions and classes to add logging, caching, validation, and retry logic without modifying original code.
- JavaScript JavaScript Error Handling Patterns and Best Practices
Master JavaScript error handling with custom error classes, async error patterns, Result types, error boundaries, and strategies for robust production code.
- JavaScript JavaScript Module Federation Explained
Understand Module Federation for micro-frontends -- share dependencies at runtime, load remote modules dynamically, and compose independent apps into one.
- JavaScript The Observable Pattern in JavaScript
Implement the Observable pattern in JavaScript to build reactive event streams, compose data pipelines, and manage async flows without third-party libraries.