JavaScript Design Patterns
Introduction
Design patterns are reusable solutions to common problems in software design. They represent best practices used by experienced developers to solve recurring issues. In JavaScript, design patterns help us write maintainable, scalable, and robust code.
Think of design patterns as templates for solving problems that occur frequently in software development. Rather than reinventing solutions each time, we can apply established patterns that have been refined over time by the programming community.
In this tutorial, we'll explore several important JavaScript design patterns, understand when to use them, and see how they can improve your code organization.
Why Learn Design Patterns?
Before diving into specific patterns, let's understand why design patterns are valuable:
- Code Reusability - They provide proven solutions that can be adapted to different situations
- Common Vocabulary - They give developers shared terminology to discuss complex designs
- Best Practices - They reflect accumulated experience from many developers
- Scalability - They help structure code in ways that make expansion easier
- Maintainability - They organize code to be more intuitive and easier to maintain
Categories of Design Patterns
JavaScript design patterns typically fall into three categories:
- Creational Patterns - Focus on object creation mechanisms
- Structural Patterns - Deal with object composition and relationships
- Behavioral Patterns - Concerned with communication between objects
Let's explore some of the most common and useful patterns in each category.
Creational Patterns
Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides a global point of access to it.
Example:
class DatabaseConnection {
constructor(host, username, password) {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.host = host;
this.username = username;
this.password = password;
this.connected = false;
// Store the instance
DatabaseConnection.instance = this;
}
connect() {
if (this.connected) {
console.log('Already connected!');
return;
}
console.log(`Connecting to database at ${this.host} with username ${this.username}...`);
this.connected = true;
}
disconnect() {
if (!this.connected) {
console.log('Not connected!');
return;
}
console.log('Disconnecting from database...');
this.connected = false;
}
}
// Usage
const dbConnection1 = new DatabaseConnection('localhost', 'admin', 'password123');
const dbConnection2 = new DatabaseConnection('another-host', 'root', 'different-password');
console.log(dbConnection1 === dbConnection2); // true
dbConnection1.connect();
dbConnection2.connect(); // Notice this uses the same instance
Output:
true
Connecting to database at localhost with username admin...
Already connected!
When to use: Use the Singleton pattern when you need exactly one instance of a class that is accessible from a well-known access point, such as database connections, configuration settings, or logging services.
Factory Pattern
The Factory pattern provides an interface for creating objects without specifying their exact class.
Example:
// Vehicle factory
class VehicleFactory {
createVehicle(type) {
switch(type) {
case 'car':
return new Car();
case 'truck':
return new Truck();
case 'motorcycle':
return new Motorcycle();
default:
throw new Error(`Vehicle type ${type} not supported.`);
}
}
}
class Car {
constructor() {
this.type = 'car';
this.wheels = 4;
}
drive() {
return `Driving a ${this.type} with ${this.wheels} wheels.`;
}
}
class Truck {
constructor() {
this.type = 'truck';
this.wheels = 6;
}
drive() {
return `Driving a ${this.type} with ${this.wheels} wheels.`;
}
}
class Motorcycle {
constructor() {
this.type = 'motorcycle';
this.wheels = 2;
}
drive() {
return `Riding a ${this.type} with ${this.wheels} wheels.`;
}
}
// Usage
const factory = new VehicleFactory();
const car = factory.createVehicle('car');
const truck = factory.createVehicle('truck');
const motorcycle = factory.createVehicle('motorcycle');
console.log(car.drive());
console.log(truck.drive());
console.log(motorcycle.drive());
Output:
Driving a car with 4 wheels.
Driving a truck with 6 wheels.
Riding a motorcycle with 2 wheels.
When to use: Use the Factory pattern when you need to create objects without exposing the creation logic to the client and when you want to refer to newly created objects using a common interface.
Structural Patterns
Module Pattern
The Module pattern encapsulates "private" functions and variables to avoid polluting the global scope and provides a public API.
Example:
// Shopping cart module
const ShoppingCart = (function() {
// Private variables and methods
let items = [];
function calculateTotal() {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
// Public API
return {
addItem: function(item) {
items.push(item);
console.log(`Added ${item.name} to cart.`);
},
removeItem: function(itemName) {
const index = items.findIndex(item => item.name === itemName);
if (index !== -1) {
items.splice(index, 1);
console.log(`Removed ${itemName} from cart.`);
}
},
getItems: function() {
return [...items]; // Return a copy to prevent direct access
},
getTotal: function() {
return calculateTotal();
}
};
})();
// Usage
ShoppingCart.addItem({ name: 'Laptop', price: 999, quantity: 1 });
ShoppingCart.addItem({ name: 'Headphones', price: 99, quantity: 2 });
console.log('Items:', ShoppingCart.getItems());
console.log('Total:', ShoppingCart.getTotal());
ShoppingCart.removeItem('Headphones');
console.log('Items after removal:', ShoppingCart.getItems());
console.log('Total after removal:', ShoppingCart.getTotal());
Output:
Added Laptop to cart.
Added Headphones to cart.
Items: [{ name: 'Laptop', price: 999, quantity: 1 }, { name: 'Headphones', price: 99, quantity: 2 }]
Total: 1197
Removed Headphones from cart.
Items after removal: [{ name: 'Laptop', price: 999, quantity: 1 }]
Total after removal: 999
When to use: Use the Module pattern when you want to organize and encapsulate related functionality while avoiding global namespace pollution.
Decorator Pattern
The Decorator pattern adds new functionality to objects without altering their structure.
Example:
// Base component
class Coffee {
cost() {
return 5;
}
description() {
return 'Basic coffee';
}
}
// Decorator
class MilkDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 1.5;
}
description() {
return `${this.coffee.description()}, with milk`;
}
}
// Another decorator
class CaramelDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 2;
}
description() {
return `${this.coffee.description()}, with caramel`;
}
}
// Usage
let myCoffee = new Coffee();
console.log(`${myCoffee.description()} costs $${myCoffee.cost()}`);
// Add milk
myCoffee = new MilkDecorator(myCoffee);
console.log(`${myCoffee.description()} costs $${myCoffee.cost()}`);
// Add caramel
myCoffee = new CaramelDecorator(myCoffee);
console.log(`${myCoffee.description()} costs $${myCoffee.cost()}`);
Output:
Basic coffee costs $5
Basic coffee, with milk costs $6.5
Basic coffee, with milk, with caramel costs $8.5
When to use: Use the Decorator pattern when you want to add responsibilities to objects dynamically without affecting other objects of the same class.
Behavioral Patterns
Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
Example:
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received update: ${data}`);
}
}
// Usage
// Create a news publisher (subject)
const newsPublisher = new Subject();
// Create subscribers (observers)
const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');
const observer3 = new Observer('Observer 3');
// Subscribe to the news
newsPublisher.subscribe(observer1);
newsPublisher.subscribe(observer2);
newsPublisher.subscribe(observer3);
// Publish some news
newsPublisher.notify('Breaking news: JavaScript design patterns are awesome!');
// Observer 2 unsubscribes
newsPublisher.unsubscribe(observer2);
console.log('Observer 2 unsubscribed.');
// Publish more news
newsPublisher.notify('More news: New JavaScript framework released!');
Output:
Observer 1 received update: Breaking news: JavaScript design patterns are awesome!
Observer 2 received update: Breaking news: JavaScript design patterns are awesome!
Observer 3 received update: Breaking news: JavaScript design patterns are awesome!
Observer 2 unsubscribed.
Observer 1 received update: More news: New JavaScript framework released!
Observer 3 received update: More news: New JavaScript framework released!
When to use: Use the Observer pattern when changes to one object may require changing others, and you don't know how many objects need to change.
Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.
Example:
// Strategy interface (in JS, we use duck typing)
class PaymentStrategy {
pay(amount) {
throw new Error('pay() method must be implemented');
}
}
// Concrete strategies
class CreditCardStrategy extends PaymentStrategy {
constructor(cardNumber, name, cvv, expirationDate) {
super();
this.cardNumber = cardNumber;
this.name = name;
this.cvv = cvv;
this.expirationDate = expirationDate;
}
pay(amount) {
console.log(`Paid $${amount} using credit card (${this.cardNumber.slice(-4)})`);
return true;
}
}
class PayPalStrategy extends PaymentStrategy {
constructor(email) {
super();
this.email = email;
}
pay(amount) {
console.log(`Paid $${amount} using PayPal (${this.email})`);
return true;
}
}
class CryptoStrategy extends PaymentStrategy {
constructor(address) {
super();
this.address = address;
}
pay(amount) {
console.log(`Paid $${amount} using Cryptocurrency (${this.address.slice(0, 6)}...)`);
return true;
}
}
// Context
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
calculateTotal() {
return this.items.reduce((total, item) => total + item.price, 0);
}
checkout(paymentStrategy) {
const amount = this.calculateTotal();
return paymentStrategy.pay(amount);
}
}
// Usage
const cart = new ShoppingCart();
cart.addItem({ name: 'JavaScript Book', price: 29.99 });
cart.addItem({ name: 'Keyboard', price: 79.99 });
// Pay with different strategies
const creditCardStrategy = new CreditCardStrategy('1234567890123456', 'John Doe', '123', '12/25');
const paypalStrategy = new PayPalStrategy('[email protected]');
const cryptoStrategy = new CryptoStrategy('0x71C7656EC7ab88b098defB751B7401B5f6d8976F');
console.log(`Cart total: $${cart.calculateTotal()}`);
cart.checkout(creditCardStrategy);
cart.checkout(paypalStrategy);
cart.checkout(cryptoStrategy);
Output:
Cart total: $109.98
Paid $109.98 using credit card (3456)
Paid $109.98 using PayPal ([email protected])
Paid $109.98 using Cryptocurrency (0x71C7...
When to use: Use the Strategy pattern when you want to define a family of algorithms, encapsulate each one, and make them interchangeable.
Real-World Application: Building a UI Component Library
Let's combine multiple design patterns to create a simple UI component library:
// Singleton Pattern for the ComponentRegistry
const ComponentRegistry = (function() {
let instance;
function createInstance() {
const components = new Map();
return {
register: function(name, component) {
components.set(name, component);
console.log(`Component "${name}" registered.`);
},
get: function(name) {
if (!components.has(name)) {
console.error(`Component "${name}" not found.`);
return null;
}
return components.get(name);
},
list: function() {
return Array.from(components.keys());
}
};
}
return {
getInstance: function() {
if (!instance) {
instance = createInstance();
}
return instance;
}
};
})();
// Factory Pattern for creating components
class ComponentFactory {
createComponent(type, config) {
switch(type) {
case 'button':
return new Button(config);
case 'input':
return new Input(config);
case 'card':
return new Card(config);
default:
throw new Error(`Component type ${type} not supported.`);
}
}
}
// Base Component
class Component {
constructor(config) {
this.config = config || {};
}
render() {
throw new Error('render() method must be implemented');
}
}
// Component implementations
class Button extends Component {
render() {
return `<button class="${this.config.className || ''}"
style="${this.config.style || ''}">${this.config.text || 'Button'}</button>`;
}
}
class Input extends Component {
render() {
return `<input type="${this.config.type || 'text'}"
placeholder="${this.config.placeholder || ''}"
class="${this.config.className || ''}"/>`;
}
}
class Card extends Component {
render() {
return `<div class="card ${this.config.className || ''}">
<div class="card-header">${this.config.title || 'Card Title'}</div>
<div class="card-body">${this.config.content || ''}</div>
</div>`;
}
}
// Observer Pattern for theme changes
class ThemeManager {
constructor() {
this.observers = [];
this.currentTheme = 'light';
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
setTheme(theme) {
this.currentTheme = theme;
this.notifyAll();
}
notifyAll() {
this.observers.forEach(observer => observer.onThemeChange(this.currentTheme));
}
}
// Usage
// Initialize component registry
const registry = ComponentRegistry.getInstance();
const factory = new ComponentFactory();
const themeManager = new ThemeManager();
// Create and register components
const primaryButton = factory.createComponent('button', {
text: 'Submit',
className: 'btn-primary'
});
const searchInput = factory.createComponent('input', {
type: 'search',
placeholder: 'Search...'
});
const userCard = factory.createComponent('card', {
title: 'User Profile',
content: 'User information goes here'
});
registry.register('primaryButton', primaryButton);
registry.register('searchInput', searchInput);
registry.register('userCard', userCard);
// Make components theme-aware
[primaryButton, searchInput, userCard].forEach(component => {
themeManager.subscribe({
onThemeChange: (theme) => {
component.config.theme = theme;
console.log(`Component updated to ${theme} theme`);
}
});
});
// Render components
console.log('Available components:', registry.list());
console.log(registry.get('primaryButton').render());
console.log(registry.get('searchInput').render());
console.log(registry.get('userCard').render());
// Change theme
themeManager.setTheme('dark');
Output:
Component "primaryButton" registered.
Component "searchInput" registered.
Component "userCard" registered.
Available components: ["primaryButton", "searchInput", "userCard"]
<button class="btn-primary" style="">Submit</button>
<input type="search" placeholder="Search..." class=""/>
<div class="card ">
<div class="card-header">User Profile</div>
<div class="card-body">User information goes here</div>
</div>
Component updated to dark theme
Component updated to dark theme
Component updated to dark theme
Summary
Design patterns are powerful tools in your JavaScript development arsenal. They provide tested solutions to common problems and help structure your code in a maintainable way. In this tutorial, we've covered:
-
Creational Patterns
- Singleton Pattern
- Factory Pattern
-
Structural Patterns
- Module Pattern
- Decorator Pattern
-
Behavioral Patterns
- Observer Pattern
- Strategy Pattern
Understanding these patterns will help you write more maintainable, reusable, and elegant code. As you become more familiar with them, you'll start recognizing situations where they can be applied.
Additional Resources
To deepen your understanding of JavaScript design patterns:
- Learning JavaScript Design Patterns by Addy Osmani
- JavaScript Patterns by Lydia Hallie
- Design Patterns: Elements of Reusable Object-Oriented Software (The original "Gang of Four" book)
Exercises
-
Implement a Logger Singleton: Create a logging utility that can be used throughout your application while ensuring only one instance exists.
-
Create a Theme Decorator: Build a component system where decorators can add different themes to UI elements without changing their core functionality.
-
Build a Command Pattern: Implement an undo/redo functionality using the Command pattern for a simple text editor.
-
Observer for Form Validation: Use the Observer pattern to implement real-time form validation that updates multiple UI elements when a form field changes.
-
MVC Architecture: Combine the patterns you've learned to implement a simple Model-View-Controller architecture for a to-do list application.
By practicing these patterns in real-world scenarios, you'll gain a deeper understanding of when and how to apply them effectively in your projects.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)