|

JavaScript Design Patterns 2026: 10 Patterns Every Developer Must Know

Design patterns are proven solutions to recurring problems in software design. They are not code you copy-paste — they are templates for thinking about architecture. Knowing patterns helps you recognize when a problem has already been solved and communicate solutions using a shared vocabulary. This guide covers the 10 JavaScript design patterns you will encounter and use most often in real-world applications.

This lesson assumes familiarity with classes, closures, and ES modules. If you are not comfortable with these foundations, review them first — patterns build on these concepts.

1. Module Pattern

The module pattern encapsulates private state and exposes a public API. Before ES modules existed, this was the primary way to organize JavaScript code. Today, ES modules are the standard, but understanding the pattern helps you write better modules.

// Classic module pattern using closures
const counterModule = (() => {
  let count = 0; // Private state

  return {
    increment() { count++; },
    decrement() { count--; },
    getCount() { return count; },
  };
})();

counterModule.increment();
counterModule.increment();
console.log(counterModule.getCount()); // 2
console.log(counterModule.count);       // undefined (private!)

// Modern equivalent: ES module
// counter.js
let count = 0; // Module-scoped (private by default)

export function increment() { count++; }
export function decrement() { count--; }
export function getCount() { return count; }

ES modules give you the module pattern for free — module-scoped variables are private, and you explicitly export the public API. Use ES modules in new code; recognize the closure-based pattern in legacy codebases.

2. Singleton Pattern

A singleton ensures a class has only one instance. Database connections, configuration objects, and loggers are classic singletons — you want exactly one shared instance.

// ES module singletons: automatic!
// db.js
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
});

export default pool;
// Every file that imports db.js gets the SAME pool instance

// Class-based singleton (when you need lazy initialization)
class Logger {
  static #instance = null;

  #logs = [];

  constructor() {
    if (Logger.#instance) return Logger.#instance;
    Logger.#instance = this;
  }

  log(message) {
    this.#logs.push({ message, timestamp: Date.now() });
    console.log(`[LOG] ${message}`);
  }

  getLogs() { return [...this.#logs]; }
}

const logger1 = new Logger();
const logger2 = new Logger();
console.log(logger1 === logger2); // true

In JavaScript, ES modules are the simplest singleton. Since modules are cached after first import, you automatically get one instance. Only use class-based singletons when you need lazy initialization or more complex lifecycle management.

3. Observer Pattern

The observer pattern (also called pub/sub) lets objects subscribe to events and get notified when something happens. DOM events, Node.js EventEmitter, and React state management all use this pattern.

class EventBus {
  #listeners = new Map();

  on(event, callback) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(callback);
    // Return unsubscribe function
    return () => this.#listeners.get(event)?.delete(callback);
  }

  emit(event, data) {
    const callbacks = this.#listeners.get(event);
    if (callbacks) {
      for (const cb of callbacks) cb(data);
    }
  }

  once(event, callback) {
    const unsubscribe = this.on(event, (data) => {
      callback(data);
      unsubscribe();
    });
    return unsubscribe;
  }
}

// Usage
const bus = new EventBus();

const unsubscribe = bus.on('user:login', (user) => {
  console.log(`${user.name} logged in`);
});

bus.on('user:login', (user) => {
  analytics.track('login', { userId: user.id });
});

bus.emit('user:login', { id: 1, name: 'Chirag' });
// Both listeners fire

unsubscribe(); // First listener removed

The observer pattern decouples the publisher from subscribers. The login function does not need to know about analytics, logging, or notifications — it just emits an event. This is the foundation of DOM events and how frameworks like React trigger re-renders.

4. Factory Pattern

A factory creates objects without specifying their exact class. Use it when object creation is complex, conditional, or requires configuration.

// Simple factory function
function createNotification(type, message) {
  const base = { id: crypto.randomUUID(), message, createdAt: new Date() };

  switch (type) {
    case 'email':
      return { ...base, type: 'email', subject: message.slice(0, 50), send: sendEmail };
    case 'sms':
      return { ...base, type: 'sms', phone: null, send: sendSMS };
    case 'push':
      return { ...base, type: 'push', icon: 'bell', send: sendPush };
    default:
      throw new Error(`Unknown notification type: ${type}`);
  }
}

// Usage — caller doesn't know or care about the creation details
const notification = createNotification('email', 'Your order shipped!');
notification.send();

// Abstract factory for database adapters
function createDatabase(type) {
  switch (type) {
    case 'postgres':
      return new PostgresAdapter(process.env.PG_URL);
    case 'mongodb':
      return new MongoAdapter(process.env.MONGO_URL);
    case 'sqlite':
      return new SQLiteAdapter(process.env.SQLITE_PATH);
    default:
      throw new Error(`Unsupported database: ${type}`);
  }
}

const db = createDatabase(process.env.DB_TYPE);

Factories shine when the construction logic is complex or you need to create different objects based on runtime configuration. They are far more common in JavaScript than the class-heavy patterns you see in Java tutorials.

5. Strategy Pattern

The strategy pattern lets you swap algorithms at runtime. Instead of a long if/else or switch chain, you define each algorithm as a separate function and select one dynamically.

// Payment processing strategies
const paymentStrategies = {
  creditCard: async (amount, details) => {
    return stripe.charges.create({ amount, source: details.token });
  },
  paypal: async (amount, details) => {
    return paypal.createPayment({ amount, payerId: details.payerId });
  },
  crypto: async (amount, details) => {
    return cryptoGateway.processPayment({ amount, wallet: details.walletAddress });
  },
};

async function processPayment(method, amount, details) {
  const strategy = paymentStrategies[method];
  if (!strategy) throw new Error(`Unsupported payment method: ${method}`);
  return strategy(amount, details);
}

// Usage
await processPayment('creditCard', 4999, { token: 'tok_visa' });
await processPayment('paypal', 4999, { payerId: 'PP-123' });

// Adding a new payment method requires ZERO changes to processPayment
paymentStrategies.applePay = async (amount, details) => {
  return apple.pay({ amount, merchantId: details.merchantId });
};

In JavaScript, the strategy pattern is usually just an object mapping keys to functions. You do not need classes or interfaces — functions are the natural unit of strategy in a functional language.

6. Decorator Pattern

Decorators add behavior to objects or functions without modifying them. They wrap the original and extend its capabilities.

// Function decorator: add logging to any function
function withLogging(fn, label) {
  return async function (...args) {
    console.log(`[${label}] Called with:`, args);
    const start = performance.now();
    try {
      const result = await fn(...args);
      console.log(`[${label}] Returned in ${(performance.now() - start).toFixed(1)}ms`);
      return result;
    } catch (error) {
      console.error(`[${label}] Failed:`, error.message);
      throw error;
    }
  };
}

// Function decorator: add caching
function withCache(fn, ttlMs = 60000) {
  const cache = new Map();

  return async function (...args) {
    const key = JSON.stringify(args);
    const cached = cache.get(key);

    if (cached && Date.now() - cached.time < ttlMs) {
      return cached.value;
    }

    const result = await fn(...args);
    cache.set(key, { value: result, time: Date.now() });
    return result;
  };
}

// Compose decorators
const getUser = withLogging(withCache(fetchUserFromDB, 30000), 'getUser');
await getUser(42); // Fetches from DB, caches, logs
await getUser(42); // Returns cached result, logs

Decorators are powerful for cross-cutting concerns: logging, caching, retry logic, rate limiting, and authentication. They keep the core function clean while layering on behavior declaratively.

7. Facade Pattern

A facade provides a simple interface to a complex subsystem. You use facades every day — fetch() is a facade over the complex HTTP protocol, and jQuery's $() was a facade over inconsistent browser APIs.

// Complex subsystem: multiple APIs and transformations
class AnalyticsFacade {
  #mixpanel;
  #googleAnalytics;
  #internalLogger;

  constructor(config) {
    this.#mixpanel = new MixpanelClient(config.mixpanelToken);
    this.#googleAnalytics = new GAClient(config.gaId);
    this.#internalLogger = new Logger(config.logEndpoint);
  }

  // Simple interface that hides the complexity
  trackEvent(name, properties = {}) {
    const enrichedProps = {
      ...properties,
      timestamp: Date.now(),
      sessionId: this.#getSessionId(),
      userAgent: navigator.userAgent,
    };

    // Fire to all providers simultaneously
    Promise.allSettled([
      this.#mixpanel.track(name, enrichedProps),
      this.#googleAnalytics.event(name, enrichedProps),
      this.#internalLogger.log('event', { name, ...enrichedProps }),
    ]);
  }

  trackPageView(path) {
    this.trackEvent('page_view', { path });
  }

  identifyUser(userId, traits) {
    this.#mixpanel.identify(userId, traits);
    this.#googleAnalytics.setUserId(userId);
  }
}

// Usage: simple and clean
const analytics = new AnalyticsFacade(config);
analytics.trackEvent('button_click', { button: 'signup' });
analytics.trackPageView('/pricing');

The facade hides the complexity of managing multiple analytics providers. Callers interact with one clean API instead of coordinating three different SDKs.

8. Proxy Pattern

A proxy wraps an object and controls access to it. JavaScript has a built-in Proxy object that makes this pattern first-class.

// Validation proxy: prevent invalid property assignments
function createValidatedUser(user) {
  return new Proxy(user, {
    set(target, property, value) {
      if (property === 'age' && (typeof value !== 'number' || value < 0)) {
        throw new TypeError('Age must be a positive number');
      }
      if (property === 'email' && !value.includes('@')) {
        throw new TypeError('Invalid email format');
      }
      target[property] = value;
      return true;
    },
  });
}

const user = createValidatedUser({ name: 'Chirag', age: 28 });
user.age = 29;     // Works
user.age = -5;     // TypeError: Age must be a positive number
user.email = 'bad'; // TypeError: Invalid email format

// Lazy loading proxy
function createLazyLoader(loadFn) {
  let data = null;
  let loaded = false;

  return new Proxy({}, {
    get(target, property) {
      if (!loaded) {
        data = loadFn();
        loaded = true;
      }
      return data[property];
    },
  });
}

const config = createLazyLoader(() => JSON.parse(fs.readFileSync('config.json')));
// Config file is only read when first accessed

You have already encountered the Proxy pattern in the Proxy & Reflect lesson. Design patterns like validation, lazy loading, access control, and change tracking are built directly on JavaScript's Proxy object.

9. Iterator Pattern

The iterator pattern provides a standard way to traverse a collection without exposing its internal structure. JavaScript has first-class support via the iterable protocol.

// Custom iterable: paginated API results
class PaginatedResults {
  #fetchPage;
  #pageSize;

  constructor(fetchPage, pageSize = 20) {
    this.#fetchPage = fetchPage;
    this.#pageSize = pageSize;
  }

  async *[Symbol.asyncIterator]() {
    let page = 1;
    let hasMore = true;

    while (hasMore) {
      const results = await this.#fetchPage(page, this.#pageSize);
      yield* results.items;
      hasMore = results.items.length === this.#pageSize;
      page++;
    }
  }
}

// Usage: iterate over all pages transparently
const allUsers = new PaginatedResults(
  (page, size) => api.get(`/users?page=${page}&limit=${size}`)
);

for await (const user of allUsers) {
  console.log(user.name);
  // Automatically fetches next page when current page is exhausted
}

The iterator pattern is already built into JavaScript through for...of, the spread operator, and destructuring. Generators (function*) and async generators (async function*) are the most elegant way to create custom iterators.

10. Middleware Pattern

The middleware pattern (also called chain of responsibility) processes a request through a series of handlers. Each handler can modify the request, respond, or pass to the next handler. Express.js made this pattern famous in the JavaScript world.

// Build your own middleware pipeline
class Pipeline {
  #middlewares = [];

  use(fn) {
    this.#middlewares.push(fn);
    return this; // Enable chaining
  }

  async execute(context) {
    let index = 0;

    const next = async () => {
      if (index < this.#middlewares.length) {
        const middleware = this.#middlewares[index++];
        await middleware(context, next);
      }
    };

    await next();
    return context;
  }
}

// Usage
const pipeline = new Pipeline();

pipeline
  .use(async (ctx, next) => {
    ctx.startTime = Date.now();
    await next();
    ctx.duration = Date.now() - ctx.startTime;
  })
  .use(async (ctx, next) => {
    console.log(`Processing: ${ctx.url}`);
    await next();
  })
  .use(async (ctx, next) => {
    ctx.result = await fetchData(ctx.url);
    await next();
  });

const result = await pipeline.execute({ url: '/api/users' });
console.log(result.duration); // Total processing time

The middleware pattern is everywhere: Express, Koa, Redux, and build tools like Webpack all use middleware pipelines. Understanding this pattern is essential for working with any modern JavaScript framework.

When to Use Patterns (and When Not To)

Do use patterns when you recognize a problem they solve. If you are writing a notification system that needs to support email, SMS, and push, the strategy pattern is a natural fit. If you are building an event-driven UI, the observer pattern is the right tool.

Do not force patterns where they are not needed. A simple function does not need a factory. A config object does not need a singleton class. Over-engineering with patterns creates complexity that slows your team down.

Learn to recognize patterns in existing code. You are already using patterns — Array.map() is the iterator pattern, addEventListener is the observer pattern, fetch() is a facade. Naming them helps you communicate with other developers and make better architectural decisions.

The goal is not to memorize every pattern from the Gang of Four book. The goal is to develop an intuition for when a problem maps to a known solution and apply that solution cleanly. Master these 10 patterns and you will handle the vast majority of architectural challenges in JavaScript applications.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *