Design patterns are proven solutions to problems that keep showing up in software. We don’t need to memorize all 23 GoF patterns, but knowing the most common ones helps us write cleaner code and recognize them in frameworks we already use. Let’s look at seven patterns that come up most in JavaScript.
Singleton — One Instance, Ever
A Singleton ensures only one instance of a class exists. Useful for things like a config manager, logger, or database connection pool.
In modern JS, the simplest Singleton is just a module-level instance — since ES modules are cached, the same instance is returned every time.
// logger.js
class Logger {
#logs = [];
log(msg) {
this.#logs.push({ msg, time: Date.now() });
console.log(`[LOG] ${msg}`);
}
getHistory() { return [...this.#logs]; }
}
export const logger = new Logger(); // single instance, module-cached
Anyone who imports logger gets the exact same object. No need for getInstance() gymnastics — the module system handles it.
If we need a class-based approach (for interviews):
class AppConfig {
static #instance;
#settings = {};
static getInstance() {
if (!AppConfig.#instance) AppConfig.#instance = new AppConfig();
return AppConfig.#instance;
}
set(key, val) { this.#settings[key] = val; }
get(key) { return this.#settings[key]; }
}
const a = AppConfig.getInstance();
const b = AppConfig.getInstance();
console.log(a === b); // true
Factory — Create Without Specifying the Exact Class
A Factory function creates objects without the caller knowing the exact class or construction details. The caller says what they want, and the factory figures out how to build it.
class PostgresDB {
query(sql) { console.log(`Postgres: ${sql}`); }
}
class MongoDB {
query(sql) { console.log(`Mongo: ${sql}`); }
}
function createDatabase(type) {
if (type === "postgres") return new PostgresDB();
if (type === "mongo") return new MongoDB();
throw new Error(`Unknown database: ${type}`);
}
const db = createDatabase("postgres");
db.query("SELECT * FROM users"); // Postgres: SELECT * FROM users
The caller doesn’t import or know about PostgresDB or MongoDB directly. If we add MySQL later, only the factory changes. React.createElement() is a real-world factory.
Builder — Step-by-Step Construction
The Builder pattern builds complex objects step by step, usually with a fluent API (method chaining). Great when an object has many optional configurations.
class QueryBuilder {
#table = "";
#conditions = [];
#limit = null;
from(table) { this.#table = table; return this; }
where(condition) { this.#conditions.push(condition); return this; }
limit(n) { this.#limit = n; return this; }
build() {
let sql = `SELECT * FROM ${this.#table}`;
if (this.#conditions.length) sql += ` WHERE ${this.#conditions.join(" AND ")}`;
if (this.#limit) sql += ` LIMIT ${this.#limit}`;
return sql;
}
}
const query = new QueryBuilder()
.from("users")
.where("age > 18")
.where("active = true")
.limit(10)
.build();
// "SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10"
Each method returns this so we can chain calls. The build() at the end produces the final result. Express middleware and Knex.js query builder use this pattern.
Observer — Subscribe to Changes
The Observer (or Pub/Sub) pattern lets objects subscribe to events and get notified when something happens. One object emits events, many objects listen.
class EventBus {
#listeners = new Map();
on(event, callback) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(callback);
}
off(event, callback) {
const cbs = this.#listeners.get(event) || [];
this.#listeners.set(event, cbs.filter(cb => cb !== callback));
}
emit(event, data) {
(this.#listeners.get(event) || []).forEach(cb => cb(data));
}
}
const bus = new EventBus();
bus.on("user:login", (user) => console.log(`${user} logged in`));
bus.emit("user:login", "Manish"); // "Manish logged in"
This is everywhere: addEventListener in the DOM, Node’s EventEmitter, Redux subscriptions, and Vue/React state management all use Observer under the hood.
Strategy — Swap Algorithms at Runtime
The Strategy pattern lets us define a family of algorithms and swap them out without changing the code that uses them. In JS, we just pass functions around — first-class functions make this natural.
// Define strategies as simple functions
const strategies = {
standard: (amount) => amount * 0.1, // 10% tax
reduced: (amount) => amount * 0.05, // 5% tax
exempt: () => 0 // no tax
};
function calculateTax(amount, type = "standard") {
const strategy = strategies[type];
if (!strategy) throw new Error(`Unknown tax type: ${type}`);
return strategy(amount);
}
console.log(calculateTax(100, "standard")); // 10
console.log(calculateTax(100, "reduced")); // 5
console.log(calculateTax(100, "exempt")); // 0
Adding a new tax type is just adding a new key to the strategies object. No if/else chains, no modifying existing code. This also ties directly to the Open/Closed principle from SOLID.
Decorator — Wrap to Add Behavior
The Decorator pattern wraps an object to add extra behavior without modifying the original. In JS, higher-order functions are the natural way to do this.
function withLogging(fn) {
return function (...args) {
console.log(`Calling ${fn.name} with`, args);
const result = fn(...args);
console.log(`Result:`, result);
return result;
};
}
function add(a, b) { return a + b; }
const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// Calling add with [2, 3]
// Result: 5
We can also decorate class instances:
function withTimestamp(instance) {
const original = instance.save.bind(instance);
instance.save = function (data) {
return original({ ...data, updatedAt: new Date() });
};
return instance;
}
Express middleware is essentially a decorator chain — each middleware wraps the request/response to add behavior (logging, auth, CORS) before passing it along.
Proxy Pattern — Control Access
The Proxy pattern controls access to an object. JavaScript has this built into the language with the Proxy API, but the pattern itself is about adding a layer between the caller and the real object.
function createCachedAPI(apiClient) {
const cache = new Map();
return new Proxy(apiClient, {
get(target, prop) {
if (prop === "fetch") {
return async (url) => {
if (cache.has(url)) {
console.log("Cache hit");
return cache.get(url);
}
const result = await target.fetch(url);
cache.set(url, result);
return result;
};
}
return Reflect.get(target, prop);
}
});
}
The proxy sits between the caller and the API client, transparently adding caching. The caller doesn’t know (or care) that caching is happening.
Quick Reference
| Pattern | Category | Core Idea | Real-World Example |
|---|---|---|---|
| Singleton | Creational | One instance globally | Module exports, Redux store |
| Factory | Creational | Create without specifying class | React.createElement, express() |
| Builder | Creational | Step-by-step fluent construction | Knex.js, query builders |
| Observer | Behavioral | Subscribe to events | addEventListener, EventEmitter |
| Strategy | Behavioral | Swap algorithms via functions | Sorting comparators, tax rules |
| Decorator | Structural | Wrap to add behavior | Express middleware, HOCs |
| Proxy | Structural | Control access to an object | Caching, validation, Vue reactivity |
In simple language, design patterns aren’t rules we have to follow — they’re names for solutions we’ve probably already used without knowing it. Recognizing them helps us communicate with other developers and pick the right tool when we face a familiar problem.