Skip to content
Codeloom
JavaScript

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.

·9 min read · By Codeloom
Intermediate 14 min read

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.