Toolmingo
Guides9 min read

JavaScript Design Patterns: A Practical Guide

Learn the most useful JavaScript design patterns — Singleton, Factory, Observer, Strategy, Module, Decorator, and more — with practical code examples and when to use each.

Design patterns are reusable solutions to common problems in software design. They're not code you copy — they're blueprints you adapt. Knowing them helps you write cleaner code and communicate clearly with other developers ("this is a Factory pattern" conveys a lot).

This guide covers the most practical JavaScript patterns with real examples you can use today.


Quick Reference

Pattern Category Problem It Solves
Singleton Creational One shared instance (config, cache)
Factory Creational Create objects without specifying exact class
Builder Creational Construct complex objects step by step
Observer Behavioral Notify many objects when state changes
Strategy Behavioral Swap algorithms at runtime
Command Behavioral Encapsulate actions as objects (undo/redo)
Module Structural Encapsulate private state
Decorator Structural Add behavior without modifying original
Facade Structural Simplify a complex subsystem
Proxy Structural Control access to an object

Creational Patterns

Singleton — One Instance Only

Ensures a class has only one instance and provides a global access point. Common for config stores, connection pools, and caches.

class Config {
  #data = {};

  static #instance = null;

  static getInstance() {
    if (!Config.#instance) {
      Config.#instance = new Config();
    }
    return Config.#instance;
  }

  set(key, value) {
    this.#data[key] = value;
    return this;
  }

  get(key) {
    return this.#data[key];
  }
}

// Always returns the same object
const config1 = Config.getInstance();
const config2 = Config.getInstance();

config1.set('theme', 'dark');
console.log(config2.get('theme')); // 'dark'
console.log(config1 === config2);  // true

Module-level singleton (simpler, idiomatic in JS):

// config.js — imported as a singleton by Node's module cache
export const config = {
  apiUrl: process.env.API_URL,
  timeout: 5000,
};

When to use: Shared state that must be consistent across your app. Avoid when it makes testing hard (global mutable state is a code smell).


Factory — Create Without Specifying the Class

A function or method that creates objects without exposing the exact construction logic.

function createUser(role) {
  const base = { role, createdAt: new Date() };

  switch (role) {
    case 'admin':
      return { ...base, permissions: ['read', 'write', 'delete'], dashboard: '/admin' };
    case 'editor':
      return { ...base, permissions: ['read', 'write'], dashboard: '/editor' };
    case 'viewer':
      return { ...base, permissions: ['read'], dashboard: '/home' };
    default:
      throw new Error(`Unknown role: ${role}`);
  }
}

const admin = createUser('admin');
const viewer = createUser('viewer');

Class-based factory with a registry:

class NotificationFactory {
  static #types = new Map();

  static register(type, cls) {
    this.#types.set(type, cls);
  }

  static create(type, options) {
    const Cls = this.#types.get(type);
    if (!Cls) throw new Error(`Unknown notification type: ${type}`);
    return new Cls(options);
  }
}

class EmailNotification {
  constructor({ to, subject }) { this.to = to; this.subject = subject; }
  send() { console.log(`Email to ${this.to}: ${this.subject}`); }
}

class SMSNotification {
  constructor({ phone, message }) { this.phone = phone; this.message = message; }
  send() { console.log(`SMS to ${this.phone}: ${this.message}`); }
}

NotificationFactory.register('email', EmailNotification);
NotificationFactory.register('sms', SMSNotification);

const n = NotificationFactory.create('email', { to: 'user@example.com', subject: 'Hello' });
n.send();

When to use: When object creation logic is complex or varies based on a type/condition.


Builder — Step-by-Step Construction

Separates the construction of a complex object from its representation.

class QueryBuilder {
  #table = '';
  #conditions = [];
  #columns = ['*'];
  #limit = null;
  #orderBy = null;

  from(table) {
    this.#table = table;
    return this; // enables chaining
  }

  select(...columns) {
    this.#columns = columns;
    return this;
  }

  where(condition) {
    this.#conditions.push(condition);
    return this;
  }

  orderBy(column, direction = 'ASC') {
    this.#orderBy = `${column} ${direction}`;
    return this;
  }

  take(n) {
    this.#limit = n;
    return this;
  }

  build() {
    if (!this.#table) throw new Error('Table is required');
    let sql = `SELECT ${this.#columns.join(', ')} FROM ${this.#table}`;
    if (this.#conditions.length) sql += ` WHERE ${this.#conditions.join(' AND ')}`;
    if (this.#orderBy) sql += ` ORDER BY ${this.#orderBy}`;
    if (this.#limit !== null) sql += ` LIMIT ${this.#limit}`;
    return sql;
  }
}

const query = new QueryBuilder()
  .from('users')
  .select('id', 'name', 'email')
  .where('active = true')
  .where('age > 18')
  .orderBy('name')
  .take(10)
  .build();

// SELECT id, name, email FROM users WHERE active = true AND age > 18 ORDER BY name ASC LIMIT 10

When to use: Objects with many optional parameters — avoids telescoping constructors.


Behavioral Patterns

Observer — Publish/Subscribe

Defines a one-to-many dependency: when one object changes, all dependents are notified automatically.

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); // returns unsubscribe fn
  }

  off(event, callback) {
    this.#listeners.get(event)?.delete(callback);
  }

  emit(event, ...args) {
    this.#listeners.get(event)?.forEach(cb => cb(...args));
  }
}

// Usage
const store = new EventEmitter();

const unsub = store.on('cart:updated', (cart) => {
  console.log('Cart updated:', cart.items.length, 'items');
});

store.emit('cart:updated', { items: ['apple', 'banana'] });
// Cart updated: 2 items

unsub(); // clean up listener

When to use: React to state changes across loosely coupled components. Native DOM events follow this pattern.


Strategy — Swap Algorithms at Runtime

Defines a family of algorithms, encapsulates each one, and makes them interchangeable.

// Strategies
const sortStrategies = {
  bubble(arr) {
    const a = [...arr];
    for (let i = 0; i < a.length; i++)
      for (let j = 0; j < a.length - i - 1; j++)
        if (a[j] > a[j + 1]) [a[j], a[j + 1]] = [a[j + 1], a[j]];
    return a;
  },
  quick(arr) {
    if (arr.length <= 1) return arr;
    const pivot = arr[arr.length - 1];
    const left = arr.slice(0, -1).filter(x => x <= pivot);
    const right = arr.slice(0, -1).filter(x => x > pivot);
    return [...sortStrategies.quick(left), pivot, ...sortStrategies.quick(right)];
  },
  native(arr) {
    return [...arr].sort((a, b) => a - b);
  },
};

class Sorter {
  #strategy;

  constructor(strategy = 'native') {
    this.#strategy = sortStrategies[strategy];
  }

  setStrategy(name) {
    if (!sortStrategies[name]) throw new Error(`Unknown strategy: ${name}`);
    this.#strategy = sortStrategies[name];
    return this;
  }

  sort(data) {
    return this.#strategy(data);
  }
}

const sorter = new Sorter('native');
console.log(sorter.sort([3, 1, 4, 1, 5, 9])); // [1, 1, 3, 4, 5, 9]

sorter.setStrategy('quick');
console.log(sorter.sort([3, 1, 4, 1, 5, 9])); // [1, 1, 3, 4, 5, 9]

Real-world example: Choosing a payment processor (Stripe vs PayPal), choosing a compression algorithm, switching between auth methods.


Command — Encapsulate Actions as Objects

Turns requests into standalone objects. Enables undo/redo, queuing, and logging.

class TextEditor {
  #content = '';
  #history = [];

  execute(command) {
    command.execute(this);
    this.#history.push(command);
  }

  undo() {
    const command = this.#history.pop();
    command?.undo(this);
  }

  getContent() { return this.#content; }
  setContent(text) { this.#content = text; }
}

class InsertCommand {
  #text;
  #position;
  #previous;

  constructor(text, position) {
    this.#text = text;
    this.#position = position;
  }

  execute(editor) {
    this.#previous = editor.getContent();
    const content = editor.getContent();
    editor.setContent(
      content.slice(0, this.#position) + this.#text + content.slice(this.#position)
    );
  }

  undo(editor) {
    editor.setContent(this.#previous);
  }
}

const editor = new TextEditor();
editor.execute(new InsertCommand('Hello', 0));
editor.execute(new InsertCommand(', world', 5));
console.log(editor.getContent()); // Hello, world

editor.undo();
console.log(editor.getContent()); // Hello

editor.undo();
console.log(editor.getContent()); // ''

Structural Patterns

Module Pattern — Encapsulate Private State

Groups related code and hides implementation details. Modern ES modules do this natively, but the pattern also works in non-module contexts.

// ES module as a module pattern (preferred)
// cart.js
let items = [];

export function addItem(item) {
  items = [...items, item]; // immutable update
}

export function removeItem(id) {
  items = items.filter(i => i.id !== id);
}

export function getItems() {
  return [...items]; // return copy, not reference
}

export function getTotal() {
  return items.reduce((sum, i) => sum + i.price, 0);
}

The items array is private — consumers can only interact through the exported functions.


Decorator — Add Behavior Without Modifying the Original

Wraps an object to extend its behavior. JavaScript's higher-order functions make this natural.

// Function decorator: add logging
function withLogging(fn, name = fn.name) {
  return function (...args) {
    console.log(`[${name}] called with`, args);
    const result = fn.apply(this, args);
    console.log(`[${name}] returned`, result);
    return result;
  };
}

function add(a, b) { return a + b; }
const loggedAdd = withLogging(add);
loggedAdd(2, 3); // logs call and result

// Async decorator: add retry logic
function withRetry(fn, retries = 3, delay = 500) {
  return async function (...args) {
    for (let attempt = 1; attempt <= retries; attempt++) {
      try {
        return await fn.apply(this, args);
      } catch (err) {
        if (attempt === retries) throw err;
        await new Promise(r => setTimeout(r, delay * attempt));
      }
    }
  };
}

const fetchWithRetry = withRetry(fetch, 3, 1000);
const res = await fetchWithRetry('https://api.example.com/data');

TC39 Decorator proposal (Stage 3, supported in TypeScript):

function readonly(target: any, key: string, descriptor: PropertyDescriptor) {
  descriptor.writable = false;
  return descriptor;
}

class Circle {
  @readonly
  area() { return Math.PI * this.radius ** 2; }
}

Facade — Simplify a Complex Subsystem

Provides a simple interface to a complex set of interfaces.

// Complex subsystem
class AudioDecoder { decode(file) { /* ... */ } }
class VideoDecoder { decode(file) { /* ... */ } }
class Renderer { render(video, audio) { /* ... */ } }
class BufferManager { allocate(size) { /* ... */ } }

// Facade
class MediaPlayer {
  #audio = new AudioDecoder();
  #video = new VideoDecoder();
  #renderer = new Renderer();
  #buffer = new BufferManager();

  play(file) {
    this.#buffer.allocate(file.size);
    const audio = this.#audio.decode(file);
    const video = this.#video.decode(file);
    this.#renderer.render(video, audio);
  }
}

// Client only needs the facade
const player = new MediaPlayer();
player.play(myVideoFile);

Common Mistakes

Mistake Problem Fix
Singleton for everything Hidden global state, hard to test Use dependency injection instead
Observer without cleanup Memory leaks from accumulated listeners Always return and call unsubscribe
Factory that returns null Callers must check null everywhere Throw on unknown type instead
Mutable state in Module External code can still mutate returned objects Return copies or use Object.freeze
Decorator that changes signature Breaks callers that expect original API Decorators should be transparent
Over-engineering with patterns Added complexity for simple code Patterns solve problems — apply only when the problem exists

Choosing the Right Pattern

Need exactly one instance?           → Singleton
Creating objects with complex logic? → Factory or Builder
React to state changes?              → Observer
Swap algorithms at runtime?          → Strategy
Need undo/redo or queueing?          → Command
Hide private state?                  → Module
Add behavior without changing code?  → Decorator
Simplify a complex API?              → Facade
Control/intercept access?            → Proxy

FAQ

Are design patterns language-specific? No. The GoF book used C++ and Smalltalk, but patterns apply in any OO or functional language. JavaScript patterns often look different than Java patterns due to first-class functions and prototypal inheritance.

Should I memorize all 23 GoF patterns? Focus on the ones you'll actually use: Singleton, Factory, Observer, Strategy, Module, Decorator, and Facade cover 90% of practical cases. Learn others when you encounter the specific problem they solve.

Is the Singleton pattern bad? It's controversial because it introduces global mutable state and makes testing hard. Prefer constructor injection or module-level singletons (which are testable by mocking the module). Use Singleton only when you truly need one shared instance.

What's the difference between Observer and Pub/Sub? Observer: subjects and observers are directly linked (subject knows its observers). Pub/Sub: a message broker sits in between — publishers don't know subscribers. Node's EventEmitter is Observer; BroadcastChannel or Redis pub/sub is Pub/Sub.

How are design patterns different from architectural patterns? Design patterns (like Observer, Factory) operate at the class/function level. Architectural patterns (MVC, MVVM, Event Sourcing, CQRS) operate at the system or application level.

Do functional programming patterns replace OOP patterns? Partially. FP's higher-order functions replace many creational patterns (Factory becomes a function). But patterns like Observer, Command, and Strategy translate naturally to FP (they're already about functions). React hooks are functional takes on Observer.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools