JavaScript Design Patterns

45 min read Intermediate

Learn proven solutions to common programming problems using creational, structural, and behavioral design patterns.

Singleton Pattern

Ensure a class has only one instance and provide global access to it.

JavaScript
// Class-based Singleton
class Database {
    static #instance = null;
    
    constructor() {
        if (Database.#instance) {
            return Database.#instance;
        }
        
        this.connection = null;
        Database.#instance = this;
    }
    
    connect(url) {
        if (!this.connection) {
            this.connection = `Connected to ${url}`;
        }
        return this.connection;
    }
    
    static getInstance() {
        if (!Database.#instance) {
            Database.#instance = new Database();
        }
        return Database.#instance;
    }
}

// Usage
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true

const db3 = Database.getInstance();
console.log(db1 === db3); // true


// Module-based Singleton (simpler in ES modules)
// database.js
let connection = null;

export function connect(url) {
    if (!connection) {
        connection = `Connected to ${url}`;
    }
    return connection;
}

export function getConnection() {
    return connection;
}

// All imports share the same state

Factory Pattern

Create objects without specifying their exact class.

JavaScript
// Simple Factory
class Car {
    constructor(make, model) {
        this.make = make;
        this.model = model;
    }
}

class Truck {
    constructor(make, model, capacity) {
        this.make = make;
        this.model = model;
        this.capacity = capacity;
    }
}

class VehicleFactory {
    static create(type, options) {
        switch (type) {
            case "car":
                return new Car(options.make, options.model);
            case "truck":
                return new Truck(options.make, options.model, options.capacity);
            default:
                throw new Error(`Unknown vehicle type: ${type}`);
        }
    }
}

const car = VehicleFactory.create("car", { make: "Toyota", model: "Camry" });
const truck = VehicleFactory.create("truck", { 
    make: "Ford", 
    model: "F-150", 
    capacity: 1000 
});


// Factory with registration
class NotificationFactory {
    static #creators = new Map();
    
    static register(type, creator) {
        this.#creators.set(type, creator);
    }
    
    static create(type, options) {
        const creator = this.#creators.get(type);
        if (!creator) {
            throw new Error(`No creator registered for: ${type}`);
        }
        return creator(options);
    }
}

// Register creators
NotificationFactory.register("email", (opts) => ({
    type: "email",
    send: () => console.log(`Email to ${opts.to}`)
}));

NotificationFactory.register("sms", (opts) => ({
    type: "sms",
    send: () => console.log(`SMS to ${opts.phone}`)
}));

// Use
const email = NotificationFactory.create("email", { to: "user@example.com" });
email.send();

Observer Pattern

Define a subscription mechanism to notify multiple objects about events.

JavaScript
// EventEmitter implementation
class EventEmitter {
    #events = new Map();
    
    on(event, listener) {
        if (!this.#events.has(event)) {
            this.#events.set(event, []);
        }
        this.#events.get(event).push(listener);
        
        // Return unsubscribe function
        return () => this.off(event, listener);
    }
    
    off(event, listener) {
        if (!this.#events.has(event)) return;
        
        const listeners = this.#events.get(event);
        const index = listeners.indexOf(listener);
        if (index > -1) {
            listeners.splice(index, 1);
        }
    }
    
    emit(event, ...args) {
        if (!this.#events.has(event)) return;
        
        this.#events.get(event).forEach(listener => {
            listener(...args);
        });
    }
    
    once(event, listener) {
        const wrapper = (...args) => {
            listener(...args);
            this.off(event, wrapper);
        };
        this.on(event, wrapper);
    }
}

// Usage
const store = new EventEmitter();

const unsubscribe = store.on("userLoggedIn", (user) => {
    console.log(`Welcome, ${user.name}!`);
});

store.on("userLoggedIn", (user) => {
    console.log(`Updating UI for ${user.name}`);
});

store.emit("userLoggedIn", { name: "John" });
// Welcome, John!
// Updating UI for John

unsubscribe(); // Remove first listener


// Pub/Sub (more decoupled)
class PubSub {
    static #subscribers = new Map();
    
    static subscribe(topic, callback) {
        if (!this.#subscribers.has(topic)) {
            this.#subscribers.set(topic, new Set());
        }
        this.#subscribers.get(topic).add(callback);
        
        return () => this.unsubscribe(topic, callback);
    }
    
    static unsubscribe(topic, callback) {
        this.#subscribers.get(topic)?.delete(callback);
    }
    
    static publish(topic, data) {
        this.#subscribers.get(topic)?.forEach(cb => cb(data));
    }
}

// Different modules can communicate without knowing each other
PubSub.subscribe("cart:updated", (cart) => {
    console.log("Cart badge:", cart.items.length);
});

PubSub.publish("cart:updated", { items: [1, 2, 3] });

Module Pattern

Encapsulate code into self-contained units with public/private members.

JavaScript
// IIFE Module Pattern (pre-ES6)
const Calculator = (function() {
    // Private
    let result = 0;
    
    function validate(n) {
        return typeof n === "number" && !isNaN(n);
    }
    
    // Public API
    return {
        add(n) {
            if (validate(n)) result += n;
            return this;
        },
        subtract(n) {
            if (validate(n)) result -= n;
            return this;
        },
        getResult() {
            return result;
        },
        reset() {
            result = 0;
            return this;
        }
    };
})();

Calculator.add(5).add(3).subtract(2);
console.log(Calculator.getResult()); // 6


// Revealing Module Pattern
const UserService = (function() {
    const users = [];
    
    function findById(id) {
        return users.find(u => u.id === id);
    }
    
    function add(user) {
        users.push({ ...user, id: Date.now() });
    }
    
    function remove(id) {
        const index = users.findIndex(u => u.id === id);
        if (index > -1) users.splice(index, 1);
    }
    
    function getAll() {
        return [...users];
    }
    
    // Reveal only what's needed
    return {
        add,
        remove,
        getAll,
        findById
    };
})();


// ES6 Module equivalent
// userService.js
const users = [];

export function add(user) {
    users.push({ ...user, id: Date.now() });
}

export function getAll() {
    return [...users];
}

Strategy Pattern

Define a family of algorithms and make them interchangeable.

JavaScript
// Validation strategies
const validators = {
    email: (value) => {
        const pattern = /^[\w.-]+@[\w.-]+\.\w{2,}$/;
        return pattern.test(value) ? null : "Invalid email";
    },
    
    required: (value) => {
        return value?.trim() ? null : "This field is required";
    },
    
    minLength: (min) => (value) => {
        return value.length >= min ? null : `Minimum ${min} characters`;
    },
    
    password: (value) => {
        const hasLetter = /[a-zA-Z]/.test(value);
        const hasNumber = /\d/.test(value);
        const hasMinLength = value.length >= 8;
        
        if (!hasMinLength) return "Password must be at least 8 characters";
        if (!hasLetter) return "Password must contain a letter";
        if (!hasNumber) return "Password must contain a number";
        return null;
    }
};

class FormValidator {
    #rules = new Map();
    
    addRule(field, validator) {
        if (!this.#rules.has(field)) {
            this.#rules.set(field, []);
        }
        this.#rules.get(field).push(validator);
        return this;
    }
    
    validate(data) {
        const errors = {};
        
        for (const [field, validators] of this.#rules) {
            for (const validator of validators) {
                const error = validator(data[field]);
                if (error) {
                    errors[field] = error;
                    break;
                }
            }
        }
        
        return {
            valid: Object.keys(errors).length === 0,
            errors
        };
    }
}

// Usage
const validator = new FormValidator()
    .addRule("email", validators.required)
    .addRule("email", validators.email)
    .addRule("password", validators.required)
    .addRule("password", validators.password)
    .addRule("name", validators.required)
    .addRule("name", validators.minLength(2));

const result = validator.validate({
    email: "test@example.com",
    password: "pass123",
    name: "J"
});
// { valid: false, errors: { password: "...", name: "..." } }

Decorator Pattern

Add behavior to objects dynamically without affecting other objects.

JavaScript
// Function decorators
function withLogging(fn) {
    return function(...args) {
        console.log(`Calling ${fn.name} with:`, args);
        const result = fn.apply(this, args);
        console.log(`${fn.name} returned:`, result);
        return result;
    };
}

function withTiming(fn) {
    return function(...args) {
        const start = performance.now();
        const result = fn.apply(this, args);
        console.log(`${fn.name} took ${performance.now() - start}ms`);
        return result;
    };
}

function add(a, b) {
    return a + b;
}

const enhancedAdd = withLogging(withTiming(add));
enhancedAdd(2, 3);


// Object decorator
function withCache(obj, methodName) {
    const original = obj[methodName];
    const cache = new Map();
    
    obj[methodName] = function(...args) {
        const key = JSON.stringify(args);
        
        if (cache.has(key)) {
            console.log("Cache hit");
            return cache.get(key);
        }
        
        const result = original.apply(this, args);
        cache.set(key, result);
        return result;
    };
    
    return obj;
}

const api = {
    fetchUser(id) {
        console.log("Fetching...");
        return { id, name: "User " + id };
    }
};

withCache(api, "fetchUser");
api.fetchUser(1); // Fetching...
api.fetchUser(1); // Cache hit


// Class method decorator (TC39 proposal)
function logged(target, name, descriptor) {
    const original = descriptor.value;
    
    descriptor.value = function(...args) {
        console.log(`Calling ${name}`);
        return original.apply(this, args);
    };
    
    return descriptor;
}

Command Pattern

The Command pattern encapsulates a request as an object, letting you parameterize clients with different requests, queue requests, and support undo operations.

JavaScript - Command Pattern
// Command interface
class Command {
    execute() { throw new Error('Not implemented'); }
    undo() { throw new Error('Not implemented'); }
}

// Concrete commands
class AddTextCommand extends Command {
    constructor(editor, text) {
        super();
        this.editor = editor;
        this.text = text;
    }
    
    execute() {
        this.previousPosition = this.editor.cursor;
        this.editor.insert(this.text);
    }
    
    undo() {
        this.editor.cursor = this.previousPosition;
        this.editor.delete(this.text.length);
    }
}

class DeleteTextCommand extends Command {
    constructor(editor, length) {
        super();
        this.editor = editor;
        this.length = length;
    }
    
    execute() {
        this.deletedText = this.editor.getSelection(this.length);
        this.position = this.editor.cursor;
        this.editor.delete(this.length);
    }
    
    undo() {
        this.editor.cursor = this.position;
        this.editor.insert(this.deletedText);
    }
}

// Command manager with undo/redo
class CommandManager {
    constructor() {
        this.history = [];
        this.redoStack = [];
    }
    
    execute(command) {
        command.execute();
        this.history.push(command);
        this.redoStack = []; // Clear redo stack on new command
    }
    
    undo() {
        const command = this.history.pop();
        if (command) {
            command.undo();
            this.redoStack.push(command);
        }
    }
    
    redo() {
        const command = this.redoStack.pop();
        if (command) {
            command.execute();
            this.history.push(command);
        }
    }
}

// Usage
const editor = new TextEditor();
const manager = new CommandManager();

manager.execute(new AddTextCommand(editor, "Hello "));
manager.execute(new AddTextCommand(editor, "World"));
manager.undo();  // Removes "World"
manager.redo();  // Adds "World" back

Mediator Pattern

The Mediator pattern defines an object that encapsulates how a set of objects interact, promoting loose coupling by keeping objects from referring to each other explicitly.

JavaScript - Mediator Pattern
// Chat room mediator
class ChatRoom {
    constructor() {
        this.users = new Map();
    }
    
    join(user) {
        this.users.set(user.name, user);
        user.chatRoom = this;
        this.broadcast(`${user.name} joined the chat`, user);
    }
    
    leave(user) {
        this.users.delete(user.name);
        this.broadcast(`${user.name} left the chat`, user);
    }
    
    send(message, from, to) {
        if (to) {
            // Private message
            const recipient = this.users.get(to);
            if (recipient) {
                recipient.receive(message, from.name);
            }
        } else {
            // Broadcast to all
            this.broadcast(message, from);
        }
    }
    
    broadcast(message, from) {
        this.users.forEach((user, name) => {
            if (name !== from.name) {
                user.receive(message, from.name);
            }
        });
    }
}

class User {
    constructor(name) {
        this.name = name;
        this.chatRoom = null;
    }
    
    send(message, to = null) {
        this.chatRoom.send(message, this, to);
    }
    
    receive(message, from) {
        console.log(`${this.name} received from ${from}: ${message}`);
    }
}

// Usage
const room = new ChatRoom();
const alice = new User('Alice');
const bob = new User('Bob');

room.join(alice);
room.join(bob);
alice.send('Hi everyone!');        // Bob receives
bob.send('Hey Alice!', 'Alice');   // Only Alice receives

State Pattern

The State pattern allows an object to alter its behavior when its internal state changes. The object appears to change its class.

JavaScript - State Pattern
// Traffic light state machine
class TrafficLightState {
    constructor(light) {
        this.light = light;
    }
    next() { throw new Error('Not implemented'); }
    getColor() { throw new Error('Not implemented'); }
}

class RedState extends TrafficLightState {
    next() {
        this.light.setState(new GreenState(this.light));
    }
    getColor() { return 'RED'; }
}

class YellowState extends TrafficLightState {
    next() {
        this.light.setState(new RedState(this.light));
    }
    getColor() { return 'YELLOW'; }
}

class GreenState extends TrafficLightState {
    next() {
        this.light.setState(new YellowState(this.light));
    }
    getColor() { return 'GREEN'; }
}

class TrafficLight {
    constructor() {
        this.state = new RedState(this);
    }
    
    setState(state) {
        this.state = state;
    }
    
    next() {
        this.state.next();
    }
    
    getColor() {
        return this.state.getColor();
    }
}

// Usage
const light = new TrafficLight();
console.log(light.getColor());  // RED
light.next();
console.log(light.getColor());  // GREEN
light.next();
console.log(light.getColor());  // YELLOW
light.next();
console.log(light.getColor());  // RED

// Promise-like state machine
class AsyncOperation {
    #state = 'pending';
    #value = undefined;
    #handlers = [];
    
    resolve(value) {
        if (this.#state !== 'pending') return;
        this.#state = 'fulfilled';
        this.#value = value;
        this.#handlers.forEach(h => h.onFulfilled?.(value));
    }
    
    reject(error) {
        if (this.#state !== 'pending') return;
        this.#state = 'rejected';
        this.#value = error;
        this.#handlers.forEach(h => h.onRejected?.(error));
    }
    
    then(onFulfilled, onRejected) {
        if (this.#state === 'fulfilled') {
            onFulfilled?.(this.#value);
        } else if (this.#state === 'rejected') {
            onRejected?.(this.#value);
        } else {
            this.#handlers.push({ onFulfilled, onRejected });
        }
        return this;
    }
}
Choosing Patterns
  • Singleton: When exactly one instance is needed globally
  • Factory: When object creation logic is complex or varies
  • Observer: When multiple objects need to react to changes
  • Command: When you need undo/redo or request queuing
  • State: When behavior changes based on internal state

Summary

Singleton

Single instance, global access

Factory

Create objects without specifying class

Observer

Event subscription and notification

Module

Encapsulation with public/private

Strategy

Interchangeable algorithms

Decorator

Add behavior dynamically