JavaScript Design Patterns Every Developer Should Know
Design patterns are reusable solutions to commonly occurring problems in software design. They represent best practices evolved over time by experienced software developers. In JavaScript, understanding design patterns is crucial because the language's flexibility allows for multiple ways to solve the same problem—and not all of them are equally maintainable or scalable.
Whether you're building a small utility library or architecting a large-scale application, design patterns provide a shared vocabulary and proven approaches that make your code more readable, testable, and maintainable. In this comprehensive guide, we'll explore the essential design patterns every JavaScript developer should master, with practical examples and real-world use cases.
- Reusable Solutions: Apply proven approaches to common problems
- Better Communication: Share ideas using a common vocabulary
- Improved Code Quality: Write more maintainable and testable code
- Framework Understanding: Recognize patterns in libraries like React, Vue, and Angular
- Interview Preparation: Design patterns are frequently asked in technical interviews
Understanding Pattern Categories
Design patterns are traditionally grouped into three categories, each addressing different aspects of software design. Understanding these categories helps you identify which pattern might be most appropriate for your specific problem.
The three main categories of design patterns and their primary concerns
1. The Singleton Pattern
Creational PatternThe Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is particularly useful when exactly one object is needed to coordinate actions across the system—like a configuration manager, logging service, or database connection pool.
Multiple requests always receive the same singleton instance
class DatabaseConnection {
static instance = null;
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.connection = this.createConnection();
DatabaseConnection.instance = this;
}
createConnection() {
// Simulate database connection
console.log('Creating new database connection...');
return {
host: 'localhost',
port: 5432,
connected: true
};
}
query(sql) {
console.log(`Executing: ${sql}`);
return { rows: [], affected: 0 };
}
static getInstance() {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
}
// Usage - both variables reference the SAME instance
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true - same instance!
Use Singleton sparingly! While useful for truly global resources (logging, caching, configuration), overuse can lead to hidden dependencies and make testing difficult. Consider dependency injection as an alternative for better testability.
2. The Factory Pattern
Creational PatternThe Factory pattern provides an interface for creating objects without specifying their exact classes. It's incredibly useful when you need to create different types of objects based on conditions, or when the creation logic is complex and should be centralized.
// Simple Factory for creating UI components
class Button {
constructor(text) {
this.text = text;
this.type = 'button';
}
render() {
return `<button>${this.text}</button>`;
}
}
class Input {
constructor(placeholder) {
this.placeholder = placeholder;
this.type = 'input';
}
render() {
return `<input placeholder="${this.placeholder}" />`;
}
}
class Select {
constructor(options) {
this.options = options;
this.type = 'select';
}
render() {
const opts = this.options.map(o => `<option>${o}</option>`).join('');
return `<select>${opts}</select>`;
}
}
// The Factory
class UIFactory {
static create(type, config) {
switch (type) {
case 'button':
return new Button(config.text);
case 'input':
return new Input(config.placeholder);
case 'select':
return new Select(config.options);
default:
throw new Error(`Unknown component: ${type}`);
}
}
}
// Usage
const submitBtn = UIFactory.create('button', { text: 'Submit' });
const emailInput = UIFactory.create('input', { placeholder: 'Enter email' });
// Factory Method Pattern - Let subclasses decide
class NotificationCreator {
// Factory Method - to be overridden
createNotification() {
throw new Error('Implement createNotification()');
}
send(message) {
const notification = this.createNotification();
notification.format(message);
notification.deliver();
}
}
class EmailNotificationCreator extends NotificationCreator {
createNotification() {
return new EmailNotification();
}
}
class SMSNotificationCreator extends NotificationCreator {
createNotification() {
return new SMSNotification();
}
}
class PushNotificationCreator extends NotificationCreator {
createNotification() {
return new PushNotification();
}
}
// Usage - the creator decides the type
function notifyUser(userPreference, message) {
let creator;
switch (userPreference) {
case 'email': creator = new EmailNotificationCreator(); break;
case 'sms': creator = new SMSNotificationCreator(); break;
case 'push': creator = new PushNotificationCreator(); break;
}
creator.send(message);
}
Real-World Examples of Factory Pattern:
- React.createElement() - Creates React elements based on type
- document.createElement() - DOM element factory
- jQuery $() - Creates jQuery objects from selectors
- Express middleware - Factories that return handler functions
// Real-world: API Response Factory
class ApiResponseFactory {
static success(data, message = 'Success') {
return {
success: true,
message,
data,
timestamp: new Date().toISOString()
};
}
static error(error, code = 500) {
return {
success: false,
error: error.message || error,
code,
timestamp: new Date().toISOString()
};
}
static paginated(data, page, total, perPage) {
return {
success: true,
data,
pagination: {
currentPage: page,
totalPages: Math.ceil(total / perPage),
totalItems: total,
perPage
}
};
}
}
3. The Module Pattern
Structural PatternThe Module pattern is one of the most important patterns in JavaScript. It provides a way to encapsulate private members while exposing a public API. Before ES6 modules, this was the primary way to organize code and avoid polluting the global namespace.
The Module pattern encapsulates private members and exposes only the public API
// Classic Module Pattern (IIFE)
const ShoppingCart = (function() {
// Private variables
let items = [];
let total = 0;
// Private methods
function calculateTotal() {
total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return total;
}
function findItem(id) {
return items.find(item => item.id === id);
}
// Public API
return {
addItem(product, quantity = 1) {
const existing = findItem(product.id);
if (existing) {
existing.quantity += quantity;
} else {
items.push({ ...product, quantity });
}
return calculateTotal();
},
removeItem(id) {
items = items.filter(item => item.id !== id);
return calculateTotal();
},
getTotal() {
return total;
},
getItems() {
// Return a copy to prevent external mutation
return [...items];
},
clear() {
items = [];
total = 0;
}
};
})();
// Usage
ShoppingCart.addItem({ id: 1, name: 'Laptop', price: 999 });
ShoppingCart.addItem({ id: 2, name: 'Mouse', price: 25 }, 2);
console.log(ShoppingCart.getTotal()); // 1049
console.log(ShoppingCart.getItems()); // [...]
// Private members are inaccessible
console.log(ShoppingCart.items); // undefined
console.log(ShoppingCart.calculateTotal); // undefined
Encapsulation
Hide internal implementation details and protect data from external modification.
Namespace
Avoid global scope pollution by containing all functionality within a single object.
Organization
Group related functionality together, making code easier to maintain and understand.
Reusability
Create self-contained units that can be easily reused across projects.
4. The Observer Pattern
Behavioral PatternThe Observer pattern defines a one-to-many dependency between objects. When the subject (observable) changes state, all its dependents (observers) are notified and updated automatically. This pattern is fundamental to event-driven programming and is extensively used in frameworks like React, Vue, and RxJS.
The Subject notifies all subscribed Observers when state changes
// EventEmitter - A flexible Observer implementation
class EventEmitter {
constructor() {
this.events = {};
}
// Subscribe to an event
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
// Return unsubscribe function
return () => this.off(event, callback);
}
// Subscribe once
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
// Unsubscribe
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event]
.filter(cb => cb !== callback);
}
// Emit event to all subscribers
emit(event, ...args) {
if (!this.events[event]) return;
this.events[event].forEach(callback => {
callback(...args);
});
}
}
// Usage: User Authentication State
class AuthService extends EventEmitter {
constructor() {
super();
this.user = null;
}
login(userData) {
this.user = userData;
this.emit('login', userData);
this.emit('authStateChange', { isAuthenticated: true, user: userData });
}
logout() {
const previousUser = this.user;
this.user = null;
this.emit('logout', previousUser);
this.emit('authStateChange', { isAuthenticated: false, user: null });
}
}
// Multiple observers listening to auth events
const auth = new AuthService();
// UI updates
auth.on('authStateChange', ({ isAuthenticated, user }) => {
updateNavbar(isAuthenticated, user);
});
// Analytics tracking
auth.on('login', (user) => {
analytics.track('user_login', { userId: user.id });
});
// Session management
auth.on('logout', () => {
localStorage.removeItem('session');
});
The Observer pattern is everywhere in JavaScript:
- DOM Events:
element.addEventListener('click', handler) - React/Vue: State changes trigger component re-renders
- RxJS: Observables and subscriptions
- Node.js: EventEmitter in core modules
- Redux: Store subscribers are notified on state changes
5. The Strategy Pattern
Behavioral PatternThe Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. This pattern lets the algorithm vary independently from the clients that use it. It's perfect for situations where you need different variations of an algorithm or behavior.
// Payment Strategy Pattern
// Strategy Interface (implicitly defined in JS)
class PaymentStrategy {
pay(amount) {
throw new Error('pay() must be implemented');
}
validate() {
throw new Error('validate() must be implemented');
}
}
// Concrete Strategies
class CreditCardPayment extends PaymentStrategy {
constructor(cardNumber, cvv, expiry) {
super();
this.cardNumber = cardNumber;
this.cvv = cvv;
this.expiry = expiry;
}
validate() {
return this.cardNumber.length === 16 && this.cvv.length === 3;
}
pay(amount) {
console.log(`Paid $${amount} using Credit Card ending in ${this.cardNumber.slice(-4)}`);
return { success: true, method: 'credit_card' };
}
}
class PayPalPayment extends PaymentStrategy {
constructor(email) {
super();
this.email = email;
}
validate() {
return this.email.includes('@');
}
pay(amount) {
console.log(`Paid $${amount} using PayPal account ${this.email}`);
return { success: true, method: 'paypal' };
}
}
class CryptoPayment extends PaymentStrategy {
constructor(walletAddress, currency = 'BTC') {
super();
this.walletAddress = walletAddress;
this.currency = currency;
}
validate() {
return this.walletAddress.length >= 26;
}
pay(amount) {
console.log(`Paid $${amount} in ${this.currency} to wallet ${this.walletAddress.slice(0, 8)}...`);
return { success: true, method: 'crypto', currency: this.currency };
}
}
// Context - uses the strategy
class PaymentProcessor {
constructor() {
this.strategy = null;
}
setStrategy(strategy) {
this.strategy = strategy;
}
checkout(amount) {
if (!this.strategy) {
throw new Error('Please select a payment method');
}
if (!this.strategy.validate()) {
throw new Error('Invalid payment details');
}
return this.strategy.pay(amount);
}
}
// Usage - strategy can be swapped at runtime
const processor = new PaymentProcessor();
// User selects credit card
processor.setStrategy(new CreditCardPayment('4111111111111111', '123', '12/25'));
processor.checkout(99.99);
// Later, user switches to PayPal
processor.setStrategy(new PayPalPayment('user@email.com'));
processor.checkout(49.99);
6. The Decorator Pattern
Structural PatternThe Decorator pattern allows you to add new behavior to objects dynamically by wrapping them in decorator objects. This provides a flexible alternative to subclassing for extending functionality. In JavaScript, this pattern is commonly used with higher-order functions and is the foundation of many middleware systems.
// Function Decorator Pattern
// Base function
function fetchData(url) {
return fetch(url).then(res => res.json());
}
// Decorator: Add logging
function withLogging(fn) {
return async function(...args) {
console.log(`Calling ${fn.name} with:`, args);
const start = Date.now();
try {
const result = await fn(...args);
console.log(`${fn.name} completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`${fn.name} failed:`, error);
throw error;
}
};
}
// Decorator: Add caching
function withCache(fn, ttl = 60000) {
const cache = new Map();
return async function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
console.log('Cache hit!');
return cached.data;
}
const result = await fn(...args);
cache.set(key, { data: result, timestamp: Date.now() });
return result;
};
}
// Decorator: Add retry logic
function withRetry(fn, maxRetries = 3) {
return async function(...args) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn(...args);
} catch (error) {
lastError = error;
console.log(`Attempt ${attempt} failed, retrying...`);
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
throw lastError;
};
}
// Compose decorators - order matters!
const enhancedFetch = withLogging(
withCache(
withRetry(fetchData)
)
);
// Usage - now has logging, caching, and retry!
enhancedFetch('/api/users').then(data => console.log(data));
7. The Proxy Pattern
Structural PatternThe Proxy pattern provides a surrogate or placeholder for another object to control access to it. JavaScript has built-in support for proxies via the Proxy object, making this pattern particularly powerful for validation, logging, lazy loading, and more.
// Validation Proxy
function createValidatedObject(target, schema) {
return new Proxy(target, {
set(obj, prop, value) {
const validator = schema[prop];
if (validator) {
const { valid, message } = validator(value);
if (!valid) {
throw new Error(`Invalid ${prop}: ${message}`);
}
}
obj[prop] = value;
return true;
}
});
}
// Define validation schema
const userSchema = {
age(value) {
if (typeof value !== 'number') {
return { valid: false, message: 'must be a number' };
}
if (value < 0 || value > 150) {
return { valid: false, message: 'must be between 0 and 150' };
}
return { valid: true };
},
email(value) {
if (!value.includes('@')) {
return { valid: false, message: 'must be valid email' };
}
return { valid: true };
}
};
// Usage
const user = createValidatedObject({}, userSchema);
user.name = 'John'; // OK - no validator
user.age = 25; // OK - valid age
user.email = 'john@example.com'; // OK - valid email
// user.age = -5; // Error: Invalid age: must be between 0 and 150
// user.email = 'invalid'; // Error: Invalid email: must be valid email
// Lazy Loading Proxy (Virtual Proxy)
function createLazyLoader(expensiveInit) {
let instance = null;
return new Proxy({}, {
get(target, prop) {
if (!instance) {
console.log('Initializing expensive resource...');
instance = expensiveInit();
}
return instance[prop];
}
});
}
const heavyResource = createLazyLoader(() => {
// Expensive initialization
return { data: 'expensive data', process() { return 'processed'; } };
});
// Resource not initialized yet!
console.log('Resource created but not initialized');
// First access triggers initialization
console.log(heavyResource.data); // "Initializing..." then "expensive data"
Pattern Comparison: When to Use What
| Pattern | Category | Use Case | Example |
|---|---|---|---|
| Singleton | Creational | Single shared instance | Configuration, Logging |
| Factory | Creational | Object creation logic | UI Components, API Responses |
| Module | Structural | Encapsulation & namespacing | Libraries, Services |
| Observer | Behavioral | Event-driven updates | State Management, Events |
| Strategy | Behavioral | Interchangeable algorithms | Payment, Sorting, Validation |
| Decorator | Structural | Add behavior dynamically | Middleware, HOCs |
| Proxy | Structural | Control access to objects | Validation, Lazy Loading |
Best Practices and Guidelines
When to Use Design Patterns
- Don't force it: Use patterns when they solve a real problem, not just because they exist
- Start simple: Begin with the simplest solution; refactor to patterns when complexity demands it
- Understand the problem: Make sure you understand why a pattern exists before applying it
- Consider maintenance: Patterns should make code easier to maintain, not harder
- Document your choices: When using patterns, document why you chose them
Common Anti-Patterns to Avoid
- Singleton Abuse: Using Singleton when dependency injection would be better
- Over-engineering: Applying patterns to simple problems that don't need them
- Pattern Mania: Using multiple patterns when one would suffice
- Ignoring Built-ins: Creating custom patterns when JavaScript/frameworks provide solutions
- Tight Coupling: Implementing patterns in ways that increase coupling instead of reducing it
Conclusion
Design patterns are powerful tools in your JavaScript toolkit, but they're not silver bullets. The key is understanding when and why to use each pattern, not just how to implement them. As you gain experience, you'll develop an intuition for recognizing situations where patterns apply.
Remember these core principles:
Key Takeaways
- Creational patterns handle object creation - use when instantiation logic is complex
- Structural patterns deal with composition - use to simplify relationships between objects
- Behavioral patterns manage algorithms and communication - use for complex workflows
- Modern JavaScript features (classes, modules, Proxy) make patterns cleaner to implement
- Many frameworks (React, Vue, Express) are built on these patterns - understanding them helps you use frameworks better
Start by mastering the patterns covered in this guide: Singleton, Factory, Module, Observer, Strategy, Decorator, and Proxy. These seven patterns will address the majority of architectural challenges you'll encounter in JavaScript development. As you grow, explore additional patterns like Command, State, and Mediator to expand your architectural vocabulary.