DNA⚡ JavaScriptJavaScript Design Patterns
ðŸĶ–DinosaurJavaScriptDesign PatternsArchitecture

JavaScript Design Patterns

Design patterns are battle-tested solutions to recurring problems. Singleton, Factory, Observer, Proxy, Module — knowing when and why to apply each one is what separates code that works from code that scales.

JavaScript Design Patterns

Design patterns aren't academic exercises — they're the vocabulary of software architecture. Every state management library uses Observer. Every DI container uses Factory. Every module system uses Module pattern. Recognizing these patterns in frameworks you already use is the first step to applying them intentionally.

Singleton Pattern

Ensures a class/module has exactly one instance with a global access point:

class Database {
  static #instance = null;
 
  constructor(connectionString) {
    if (Database.#instance) {
      return Database.#instance;
    }
    this.connection = connectionString;
    Database.#instance = this;
  }
 
  static getInstance(connectionString) {
    if (!Database.#instance) {
      Database.#instance = new Database(connectionString);
    }
    return Database.#instance;
  }
}
 
const db1 = Database.getInstance('postgres://...');
const db2 = Database.getInstance('different://...');
db1 === db2; // true — same instance

Module-Based Singleton (Modern JS)

ES modules are singletons by default — the module is evaluated once and cached:

// config.js — this IS a singleton
const config = {
  apiUrl: process.env.API_URL,
  timeout: 5000,
};
 
export default Object.freeze(config);

Every file that imports config gets the same object reference.

When to use: Shared resources (database connections, loggers, configuration, application state stores).

Caveat: Singletons make testing harder (global state). Prefer dependency injection where possible.

Module Pattern

Encapsulates private state using closures, exposing only a public API:

const CartModule = (() => {
  let items = [];
 
  function calculateTotal() {
    return items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }
 
  return {
    addItem(item) {
      const existing = items.find(i => i.id === item.id);
      if (existing) {
        existing.qty++;
      } else {
        items.push({ ...item, qty: 1 });
      }
    },
    removeItem(id) {
      items = items.filter(i => i.id !== id);
    },
    getTotal: calculateTotal,
    getItems: () => [...items],
  };
})();
 
CartModule.addItem({ id: 1, name: 'Shirt', price: 30 });
CartModule.getTotal(); // 30
CartModule.items;      // undefined — truly private

Modern equivalent: ES modules with unexported variables achieve the same encapsulation:

// cart.js
let items = [];
 
function calculateTotal() {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
 
export function addItem(item) { /* ... */ }
export function getTotal() { return calculateTotal(); }

When to use: Encapsulating implementation details, creating clean public APIs, preventing global namespace pollution.

Factory Pattern

Creates objects without specifying the exact class, delegating instantiation logic:

function createNotification(type, message) {
  const base = {
    id: crypto.randomUUID(),
    message,
    timestamp: Date.now(),
    read: false,
  };
 
  switch (type) {
    case 'success':
      return { ...base, type, icon: '✓', color: 'green', duration: 3000 };
    case 'error':
      return { ...base, type, icon: '✕', color: 'red', duration: 0 };
    case 'warning':
      return { ...base, type, icon: '⚠', color: 'yellow', duration: 5000 };
    case 'info':
      return { ...base, type, icon: 'â„đ', color: 'blue', duration: 4000 };
    default:
      throw new Error(`Unknown notification type: ${type}`);
  }
}
 
const error = createNotification('error', 'Request failed');
const success = createNotification('success', 'Saved!');

Abstract Factory

Creates families of related objects:

interface UIFactory {
  createButton(label: string): Button;
  createInput(placeholder: string): Input;
  createModal(title: string): Modal;
}
 
const MaterialFactory: UIFactory = {
  createButton: (label) => new MaterialButton(label),
  createInput: (placeholder) => new MaterialInput(placeholder),
  createModal: (title) => new MaterialModal(title),
};
 
const AntFactory: UIFactory = {
  createButton: (label) => new AntButton(label),
  createInput: (placeholder) => new AntInput(placeholder),
  createModal: (title) => new AntModal(title),
};
 
function renderForm(factory: UIFactory) {
  const nameInput = factory.createInput('Name');
  const submitBtn = factory.createButton('Submit');
  // All components follow the same design system
}

When to use: Object creation logic is complex, multiple object variants exist, you want to decouple creation from usage.

Observer Pattern

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

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) => {
      callback(...args);
      unsubscribe();
    });
    return unsubscribe;
  }
}

Real-World: Store with Subscribers

function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();
 
  return {
    getState: () => state,
    setState(updater) {
      const prev = state;
      state = typeof updater === 'function' ? updater(prev) : updater;
      if (state !== prev) {
        listeners.forEach(fn => fn(state, prev));
      }
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}
 
const store = createStore({ count: 0 });
const unsub = store.subscribe((next, prev) => {
  console.log(`${prev.count} → ${next.count}`);
});
store.setState(s => ({ count: s.count + 1 })); // "0 → 1"

This is the core pattern behind Redux, Zustand, and every pub/sub system.

When to use: Decoupled communication, event-driven architectures, state management, real-time updates.

Proxy Pattern

Controls access to another object, adding behavior before/after operations:

function createLoggingProxy(target, label) {
  return new Proxy(target, {
    get(obj, prop) {
      console.log(`[${label}] Read: ${String(prop)}`);
      return Reflect.get(obj, prop);
    },
    set(obj, prop, value) {
      console.log(`[${label}] Write: ${String(prop)} = ${value}`);
      return Reflect.set(obj, prop, value);
    },
  });
}
 
function createValidatingProxy(target, validators) {
  return new Proxy(target, {
    set(obj, prop, value) {
      const validate = validators[prop];
      if (validate && !validate(value)) {
        throw new TypeError(`Invalid value for ${String(prop)}`);
      }
      return Reflect.set(obj, prop, value);
    },
  });
}
 
const user = createValidatingProxy({}, {
  age: v => typeof v === 'number' && v >= 0,
  email: v => typeof v === 'string' && v.includes('@'),
});

When to use: Validation, logging, caching, lazy initialization, access control. Vue 3's reactivity system is built on Proxy.

Prototype Pattern

Creates new objects by cloning existing ones:

const vehiclePrototype = {
  init({ make, model, year }) {
    this.make = make;
    this.model = model;
    this.year = year;
    return this;
  },
  getInfo() {
    return `${this.year} ${this.make} ${this.model}`;
  },
};
 
const car = Object.create(vehiclePrototype).init({
  make: 'Toyota', model: 'Camry', year: 2024,
});

In modern JS, Object.create() and class extends replace manual prototype manipulation. The pattern is still relevant for understanding the prototype chain and structuredClone for object copying.

Iterator Pattern

Provides sequential access to elements without exposing underlying structure:

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }
 
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        return current <= end
          ? { value: current++, done: false }
          : { done: true };
      },
    };
  }
}
 
for (const n of new Range(1, 5)) console.log(n); // 1, 2, 3, 4, 5

Strategy Pattern

Defines a family of algorithms and makes them interchangeable:

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) => b.createdAt - a.createdAt,
};
 
function sortProducts(products, strategy) {
  return [...products].sort(sortStrategies[strategy]);
}

Patterns in React

PatternReact Implementation
ObserveruseState + context subscribers, Zustand subscribe()
FactoryComponent factories, createElement
StrategyRender props, callback props
ProxyHigher-Order Components wrapping behavior
SingletonContext providers, module-level stores
ModuleCustom hooks encapsulating logic
Iterator.map() over children, React.Children utilities

Interview Signal

Senior candidates demonstrate:

  1. Pattern recognition — Can identify Singleton in module exports, Observer in Redux, Factory in component creation
  2. Trade-off awareness — Singleton makes testing hard, Observer can cause memory leaks without cleanup, Factory adds indirection
  3. Modern JS equivalents — ES modules as Singleton, Proxy API for Proxy pattern, generators for Iterator
  4. Framework mapping — Connects patterns to React/Vue/Angular implementations they use daily
  5. Appropriate application — Knows when a pattern helps vs when it's over-engineering