coderain guide

How to Implement JavaScript Design Patterns

JavaScript, as a prototype-based, dynamically typed language, has unique characteristics that make design patterns both powerful and challenging to implement. Unlike static languages (e.g., Java), JavaScript lacks built-in class-based inheritance (though ES6 introduced `class` syntax as syntactic sugar over prototypes). This flexibility means design patterns in JavaScript often adapt to leverage features like closures, prototypes, and modules. Design patterns solve problems like: - **Encapsulation**: Hiding internal state and exposing only necessary methods. - **Reusability**: Avoiding redundant code across projects. - **Loose Coupling**: Reducing dependencies between components. - **Scalability**: Making it easier to extend functionality without rewriting existing code. We’ll focus on patterns most relevant to modern JavaScript (ES6+), using classes, modules, and arrow functions where appropriate.

Design patterns are reusable solutions to common software design problems. They act as blueprints for solving issues like code organization, scalability, and maintainability—especially in dynamic languages like JavaScript, where flexibility can lead to messy code without structure. Whether you’re building a small app or a large-scale system, understanding design patterns will help you write cleaner, more efficient, and easier-to-debug code.

In this guide, we’ll explore the most essential JavaScript design patterns, broken down by category (creational, structural, behavioral), with practical examples, use cases, and code implementations. By the end, you’ll know when and how to apply each pattern to solve real-world problems.

Table of Contents

  1. Introduction to Design Patterns in JavaScript
  2. Creational Patterns
  3. Structural Patterns
  4. Behavioral Patterns
  5. Conclusion
  6. References

Creational Patterns

Creational patterns handle object creation mechanisms, ensuring objects are created in a way that’s flexible and efficient for the situation.

1. Singleton Pattern

What is it? Ensures a class has only one instance and provides a global point of access to it.

When to Use It?

  • When you need a single shared resource (e.g., a database connection, logging service, or configuration manager).
  • When multiple instances could cause conflicts (e.g., a state management store like Redux’s store).

Implementation Example
In JavaScript, you can implement a Singleton using a static method to check for an existing instance. ES6 modules are inherently singletons (they’re cached after the first import), but we’ll focus on a class-based approach here:

class Logger {
  constructor() {
    // Check if an instance already exists
    if (Logger.instance) {
      return Logger.instance; // Return existing instance if it exists
    }
    // Initialize instance properties
    this.logs = [];
    Logger.instance = this; // Cache the instance
  }

  // Add a log message
  log(message) {
    this.logs.push({ message, timestamp: new Date() });
    console.log(`[LOG] ${message}`);
  }

  // Get all logs
  getLogs() {
    return this.logs;
  }

  // Static method to get the singleton instance (optional but explicit)
  static getInstance() {
    if (!Logger.instance) {
      new Logger(); // Create instance if none exists
    }
    return Logger.instance;
  }
}

// Usage
const logger1 = new Logger();
const logger2 = new Logger();

console.log(logger1 === logger2); // true (same instance)
logger1.log("Hello, Singleton!");
console.log(logger2.getLogs()); // [{ message: "Hello, Singleton!", timestamp: ... }]

Benefits:

  • Guarantees a single instance, preventing duplicate resource usage.
  • Global access simplifies sharing state across components.

Pitfalls:

  • Tight coupling: Code dependent on the Singleton becomes hard to test (you can’t mock it easily).
  • Overuse: Not every class needs to be a Singleton—overuse leads to rigid code.

2. Factory Pattern

What is it? Creates objects without exposing the instantiation logic to the client, using a “factory” method to delegate object creation.

When to Use It?

  • When you need to create multiple similar objects (e.g., different types of UI components: buttons, inputs, modals).
  • When the type of object to create is determined at runtime (e.g., a payment processor that creates credit card, PayPal, or crypto handlers).

Implementation Example
Let’s build a ShapeFactory that creates circles, squares, or triangles based on input:

// Base Shape class (optional, for shared methods)
class Shape {
  constructor() {
    if (this.constructor === Shape) {
      throw new Error("Abstract class 'Shape' cannot be instantiated directly.");
    }
  }

  draw() {
    throw new Error("Method 'draw()' must be implemented.");
  }
}

// Concrete Shapes
class Circle extends Shape {
  draw() {
    return "Drawing a circle 🔵";
  }
}

class Square extends Shape {
  draw() {
    return "Drawing a square 🟥";
  }
}

class Triangle extends Shape {
  draw() {
    return "Drawing a triangle 🔺";
  }
}

// Factory class
class ShapeFactory {
  static createShape(shapeType) {
    switch (shapeType.toLowerCase()) {
      case "circle":
        return new Circle();
      case "square":
        return new Square();
      case "triangle":
        return new Triangle();
      default:
        throw new Error(`Shape type '${shapeType}' is not supported.`);
    }
  }
}

// Usage
const circle = ShapeFactory.createShape("circle");
const square = ShapeFactory.createShape("square");

console.log(circle.draw()); // "Drawing a circle 🔵"
console.log(square.draw()); // "Drawing a square 🟥"

Benefits:

  • Decouples object creation from the client, making code easier to extend (add a new shape by adding a class and updating the factory).
  • Centralizes creation logic, reducing redundancy.

Pitfalls:

  • Can become complex if overused (e.g., too many shape types cluttering the factory).

3. Constructor Pattern

What is it? A blueprint for creating objects with shared properties and methods. In JavaScript, this is traditionally done with constructor functions or ES6 class syntax.

When to Use It?

  • When you need multiple instances of an object with similar behavior (e.g., users, products, or UI elements).

Implementation Example
ES6 class syntax simplifies the constructor pattern:

class User {
  // Constructor initializes properties
  constructor(name, email) {
    this.name = name;
    this.email = email;
    this.loggedIn = false;
  }

  // Method: Log in the user
  login() {
    this.loggedIn = true;
    console.log(`${this.name} logged in.`);
  }

  // Method: Log out the user
  logout() {
    this.loggedIn = false;
    console.log(`${this.name} logged out.`);
  }

  // Getter: Return user status
  get status() {
    return this.loggedIn ? "Online" : "Offline";
  }
}

// Usage
const user1 = new User("Alice", "[email protected]");
const user2 = new User("Bob", "[email protected]");

user1.login(); // "Alice logged in."
console.log(user1.status); // "Online"
user2.logout(); // "Bob logged out."

Benefits:

  • Cleanly organizes object creation and behavior.
  • Supports inheritance (via extends and super).

Pitfalls:

  • Forgetting to use new when instantiating can lead to unexpected behavior (e.g., this binding to the global object).

Structural Patterns

Structural patterns focus on composing objects or classes to form larger structures, improving flexibility and efficiency.

1. Module Pattern

What is it? Encapsulates code into “modules” with private and public members, preventing global scope pollution.

When to Use It?

  • When you want to hide implementation details and expose only a public API (e.g., utility libraries, plugins).

Implementation Example
JavaScript modules can be implemented with IIFEs (Immediately Invoked Function Expressions) or ES6 modules. Here’s an ES6 module example (save as counter.js):

// Private variable (only accessible inside the module)
let count = 0;

// Public methods (exposed via export)
export function increment() {
  count++;
  return count;
}

export function decrement() {
  count--;
  return count;
}

export function getCount() {
  return count;
}

// Private function (not exported)
function logChange() {
  console.log(`Count updated to ${count}`);
}

Usage (in another file):

import { increment, decrement, getCount } from './counter.js';

increment(); // count = 1
increment(); // count = 2
console.log(getCount()); // 2
decrement(); // count = 1

Benefits:

  • Encapsulation: Private members are shielded from external modification.
  • Avoids global scope pollution (no variables leaked to window).

Pitfalls:

  • ES6 modules require a module bundler (e.g., Webpack) or type="module" in HTML for browser support.

2. Adapter Pattern

What is it? Converts the interface of one class into another interface that clients expect, allowing incompatible classes to work together.

When to Use It?

  • When integrating legacy code with a new system (e.g., an old API returns data in XML, but your app expects JSON).
  • When using third-party libraries with incompatible method names.

Implementation Example
Suppose you have an old OldCalculator that uses add(a, b), but your new app expects a Calculator with sum(a, b):

// Legacy component with incompatible interface
class OldCalculator {
  add(num1, num2) {
    return num1 + num2;
  }
}

// Adapter: Wraps OldCalculator to match the new interface
class CalculatorAdapter {
  constructor() {
    this.oldCalculator = new OldCalculator(); // Delegate to legacy code
  }

  // New interface method
  sum(num1, num2) {
    return this.oldCalculator.add(num1, num2); // Call legacy method
  }
}

// Usage (client expects "sum" method)
const calculator = new CalculatorAdapter();
console.log(calculator.sum(2, 3)); // 5 (works with the new interface)

Benefits:

  • Enables reuse of existing code without modifying it.
  • Decouples client code from legacy implementations.

Pitfalls:

  • Adding unnecessary layers of indirection if overused.

3. Decorator Pattern

What is it? Dynamically adds behavior to objects without modifying their original class. It wraps objects in “decorator” objects that add functionality.

When to Use It?

  • When you need to add features to objects at runtime (e.g., adding toppings to a pizza, or plugins to a base application).

Implementation Example
Let’s build a coffee shop where customers can add milk, sugar, or whipped cream to their coffee:

// Base component: Simple Coffee
class Coffee {
  cost() {
    return 2.0; // $2 for black coffee
  }

  description() {
    return "Black Coffee";
  }
}

// Decorator 1: Milk
class MilkDecorator {
  constructor(coffee) {
    this.coffee = coffee; // Wrap the coffee object
  }

  cost() {
    return this.coffee.cost() + 0.5; // Add $0.50 for milk
  }

  description() {
    return `${this.coffee.description()}, Milk`;
  }
}

// Decorator 2: Sugar
class SugarDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }

  cost() {
    return this.coffee.cost() + 0.25; // Add $0.25 for sugar
  }

  description() {
    return `${this.coffee.description()}, Sugar`;
  }
}

// Usage: Decorate a coffee dynamically
let myCoffee = new Coffee();
myCoffee = new MilkDecorator(myCoffee); // Add milk
myCoffee = new SugarDecorator(myCoffee); // Add sugar

console.log(myCoffee.description()); // "Black Coffee, Milk, Sugar"
console.log(myCoffee.cost()); // 2.75 (2 + 0.5 + 0.25)

Benefits:

  • Adds functionality dynamically (no need to create subclasses for every combination).
  • Open/Closed Principle: Classes are open for extension but closed for modification.

Pitfalls:

  • Can lead to a large number of small decorator objects if overused.

Behavioral Patterns

Behavioral patterns focus on communication between objects, defining how they interact and distribute responsibility.

1. Observer Pattern

What is it? Defines a one-to-many dependency between objects: when one object (the “subject”) changes state, all its dependents ( “observers”) are notified and updated automatically.

When to Use It?

  • When objects need to react to changes in another object (e.g., UI components updating when a data store changes, or event listeners).

Implementation Example
A newsletter system where subscribers (observers) get notified when a new issue is published (subject):

// Subject: Newsletter (manages observers and notifies them)
class Newsletter {
  constructor() {
    this.subscribers = []; // List of observers
  }

  // Add observer
  subscribe(observer) {
    this.subscribers.push(observer);
  }

  // Remove observer
  unsubscribe(observer) {
    this.subscribers = this.subscribers.filter(sub => sub !== observer);
  }

  // Notify all observers of a new issue
  notify(newIssue) {
    this.subscribers.forEach(observer => observer.update(newIssue));
  }

  // Publish a new issue (triggers notification)
  publishIssue(issue) {
    this.notify(issue);
  }
}

// Observer: Subscriber (reacts to notifications)
class Subscriber {
  constructor(name) {
    this.name = name;
  }

  // Update method called by subject
  update(issue) {
    console.log(`${this.name} received newsletter: ${issue.title}`);
  }
}

// Usage
const newsletter = new Newsletter();
const alice = new Subscriber("Alice");
const bob = new Subscriber("Bob");

newsletter.subscribe(alice);
newsletter.subscribe(bob);

newsletter.publishIssue({ title: "JavaScript Design Patterns" }); 
// Alice received newsletter: JavaScript Design Patterns  
// Bob received newsletter: JavaScript Design Patterns  

newsletter.unsubscribe(bob);
newsletter.publishIssue({ title: "ES6 Features" }); 
// Alice received newsletter: ES6 Features (Bob is unsubscribed)

Benefits:

  • Decouples subjects and observers (subject doesn’t need to know about observer implementations).
  • Supports dynamic addition/removal of observers.

Pitfalls:

  • Observers can be notified in an unpredictable order.
  • Memory leaks if observers are not unsubscribed (e.g., a UI component that’s removed but still subscribed).

2. Strategy Pattern

What is it? Defines a family of interchangeable algorithms and lets clients switch between them at runtime.

When to Use It?

  • When you have multiple ways to solve a problem (e.g., different payment methods, sorting algorithms, or validation rules).

Implementation Example
A payment processor that supports credit card, PayPal, and Bitcoin payments:

// Strategy 1: Credit Card Payment
class CreditCardPayment {
  pay(amount) {
    return `Paid $${amount} via Credit Card (fee: $${amount * 0.02})`;
  }
}

// Strategy 2: PayPal Payment
class PayPalPayment {
  pay(amount) {
    return `Paid $${amount} via PayPal (fee: $${amount * 0.03})`;
  }
}

// Strategy 3: Bitcoin Payment
class BitcoinPayment {
  pay(amount) {
    return `Paid $${amount} via Bitcoin (fee: $0.50)`;
  }
}

// Context: Payment Processor (uses a strategy)
class PaymentProcessor {
  constructor(strategy) {
    this.strategy = strategy; // Set initial strategy
  }

  // Switch strategy at runtime
  setStrategy(strategy) {
    this.strategy = strategy;
  }

  // Execute strategy
  processPayment(amount) {
    return this.strategy.pay(amount);
  }
}

// Usage
const processor = new PaymentProcessor(new CreditCardPayment());
console.log(processor.processPayment(100)); // Paid $100 via Credit Card (fee: $2)

processor.setStrategy(new PayPalPayment());
console.log(processor.processPayment(100)); // Paid $100 via PayPal (fee: $3)

Benefits:

  • Easily switch algorithms without changing client code.
  • Encapsulates each algorithm, making them reusable.

Pitfalls:

  • Clients must be aware of different strategies to select the right one.

3. Command Pattern

What is it? Encapsulates a request as an object, allowing you to parameterize clients with different requests, queue requests, or support undo/redo.

When to Use It?

  • When you need to decouple the sender of a request from the receiver (e.g., a remote control for a TV, or a task scheduler).

Implementation Example
A remote control with buttons to turn a TV on/off and adjust volume:

// Receiver: TV (performs the actual action)
class TV {
  turnOn() {
    return "TV is ON";
  }

  turnOff() {
    return "TV is OFF";
  }

  volumeUp() {
    return "Volume increased";
  }

  volumeDown() {
    return "Volume decreased";
  }
}

// Command interface: Defines execute() method
class Command {
  execute() {
    throw new Error("Command.execute() must be implemented");
  }
}

// Concrete Commands
class TurnOnCommand extends Command {
  constructor(tv) {
    super();
    this.tv = tv; // Reference to receiver
  }

  execute() {
    return this.tv.turnOn();
  }
}

class TurnOffCommand extends Command {
  constructor(tv) {
    super();
    this.tv = tv;
  }

  execute() {
    return this.tv.turnOff();
  }
}

// Invoker: Remote Control (sends commands)
class RemoteControl {
  constructor() {
    this.commands = {}; // Stores commands for buttons
  }

  // Map a button to a command
  setCommand(button, command) {
    this.commands[button] = command;
  }

  // Press a button to execute the command
  pressButton(button) {
    if (this.commands[button]) {
      return this.commands[button].execute();
    }
    return "Button not mapped";
  }
}

// Usage
const tv = new TV();
const remote = new RemoteControl();

// Map buttons to commands
remote.setCommand("powerOn", new TurnOnCommand(tv));
remote.setCommand("powerOff", new TurnOffCommand(tv));

console.log(remote.pressButton("powerOn")); // "TV is ON"
console.log(remote.pressButton("powerOff")); // "TV is OFF"

Benefits:

  • Decouples senders (remote) from receivers (TV).
  • Supports undo/redo by storing command history.

Pitfalls:

  • Increases code complexity by introducing many small command classes.

Conclusion

Design patterns are not silver bullets, but they provide proven solutions to common problems. The key is to understand when to use each pattern—not to force them into every scenario. Start by identifying the problem (e.g., “I need to reuse legacy code” → Adapter, “I need to notify components of changes” → Observer), then choose the pattern that fits.

By mastering these patterns, you’ll write code that’s more maintainable, scalable, and collaborative. Remember: the best pattern is the one that solves your problem simply and clearly.

References