JavaScript Design Patterns Every Developer Should Know
You open a JavaScript codebase and find three different ways of creating API clients, a config object that gets imported and mutated from a dozen files, and a payment function that's one long if/else chain with a new branch for every provider. None of it is broken, exactly. It's just hard to change without breaking something else. JavaScript lets you solve the same problem in many ways, and that freedom is how codebases end up like this.
Design patterns give names to the handful of approaches that tend to hold up. This post covers seven I actually see in day-to-day JavaScript: Singleton, Factory, Module, Observer, Strategy, Decorator and Proxy. For each one you'll get working code, where it shows up in libraries you already use, and the trade-offs that come with it.
- Shared vocabulary: "Make it a strategy" is a faster code review comment than a paragraph of explanation
- Reading other people's code: React, Vue, Express and Node's core modules are full of these patterns
- Fewer reinventions: You stop solving the same structural problem from scratch on every project
- Interviews: They still come up a lot in technical interviews
Understanding Pattern Categories
The classic grouping splits patterns into three buckets. Creational ones deal with how objects get made, structural ones with how they fit together, and behavioral ones with how they talk to each other. Knowing which bucket your problem falls into narrows down the options quickly.
The three main categories of design patterns and their primary concerns
1. The Singleton Pattern
Creational PatternSome things in an app should exist exactly once: the config loader, the logger, the database connection pool. Opening a new pool on every request is a quick way to run out of connections. A Singleton guarantees there's only one instance and gives everyone the same way to get at it.
Multiple requests always receive the same singleton instance
class DatabaseConnection {
static instance = null;
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.connection = this.createConnection();
DatabaseConnection.instance = this;
}
createConnection() {
// Simulate database connection
console.log('Creating new database connection...');
return {
host: 'localhost',
port: 5432,
connected: true
};
}
query(sql) {
console.log(`Executing: ${sql}`);
return { rows: [], affected: 0 };
}
static getInstance() {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
}
// Usage - both variables reference the SAME instance
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true - same instance!
Use it sparingly. It's fine for truly global resources (logging, caching, configuration), but every Singleton is a hidden dependency, and tests start leaking state into each other because the instance survives between them. In Jest you'll end up calling jest.resetModules() or adding a reset method just to get clean tests. Passing the dependency in (plain dependency injection) is usually easier to test.
One more gotcha: in Node, an exported instance (module.exports = new Logger()) is only a singleton per resolved file path. If two copies of the same package end up in node_modules at different versions, you get two "singletons". npm ls <package> will show the duplicates.
2. The Factory Pattern
Creational PatternWhen the type of object you need depends on runtime input (a config value, a user preference, a field in an API response), new calls with if checks tend to spread across the codebase. A Factory puts that decision in one place: callers ask for "a button" or "an email notification" and don't need to know which class comes back.
// Simple Factory for creating UI components
class Button {
constructor(text) {
this.text = text;
this.type = 'button';
}
render() {
return `<button>${this.text}</button>`;
}
}
class Input {
constructor(placeholder) {
this.placeholder = placeholder;
this.type = 'input';
}
render() {
return `<input placeholder="${this.placeholder}" />`;
}
}
class Select {
constructor(options) {
this.options = options;
this.type = 'select';
}
render() {
const opts = this.options.map(o => `<option>${o}</option>`).join('');
return `<select>${opts}</select>`;
}
}
// The Factory
class UIFactory {
static create(type, config) {
switch (type) {
case 'button':
return new Button(config.text);
case 'input':
return new Input(config.placeholder);
case 'select':
return new Select(config.options);
default:
throw new Error(`Unknown component: ${type}`);
}
}
}
// Usage
const submitBtn = UIFactory.create('button', { text: 'Submit' });
const emailInput = UIFactory.create('input', { placeholder: 'Enter email' });
// Factory Method Pattern - Let subclasses decide
class NotificationCreator {
// Factory Method - to be overridden
createNotification() {
throw new Error('Implement createNotification()');
}
send(message) {
const notification = this.createNotification();
notification.format(message);
notification.deliver();
}
}
class EmailNotificationCreator extends NotificationCreator {
createNotification() {
return new EmailNotification();
}
}
class SMSNotificationCreator extends NotificationCreator {
createNotification() {
return new SMSNotification();
}
}
class PushNotificationCreator extends NotificationCreator {
createNotification() {
return new PushNotification();
}
}
// Usage - the creator decides the type
function notifyUser(userPreference, message) {
let creator;
switch (userPreference) {
case 'email': creator = new EmailNotificationCreator(); break;
case 'sms': creator = new SMSNotificationCreator(); break;
case 'push': creator = new PushNotificationCreator(); break;
}
creator.send(message);
}
Real-World Examples of Factory Pattern:
- React.createElement(): creates React elements based on type
- document.createElement(): the browser's DOM element factory
- jQuery $(): creates jQuery objects from selectors
- Express middleware: functions like
express.json()return handler functions
// Real-world: API Response Factory
class ApiResponseFactory {
static success(data, message = 'Success') {
return {
success: true,
message,
data,
timestamp: new Date().toISOString()
};
}
static error(error, code = 500) {
return {
success: false,
error: error.message || error,
code,
timestamp: new Date().toISOString()
};
}
static paginated(data, page, total, perPage) {
return {
success: true,
data,
pagination: {
currentPage: page,
totalPages: Math.ceil(total / perPage),
totalItems: total,
perPage
}
};
}
}
3. The Module Pattern
Structural PatternBefore ES modules, every script you loaded shared one global scope, and two libraries defining utils would quietly overwrite each other. The Module pattern fixed that with a closure: keep state and helpers private inside a function, return only the public API. Today import/export does most of this for you, but you'll still see the IIFE version in older code and in bundler output.
The Module pattern encapsulates private members and exposes only the public API
// Classic Module Pattern (IIFE)
const ShoppingCart = (function() {
// Private variables
let items = [];
let total = 0;
// Private methods
function calculateTotal() {
total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return total;
}
function findItem(id) {
return items.find(item => item.id === id);
}
// Public API
return {
addItem(product, quantity = 1) {
const existing = findItem(product.id);
if (existing) {
existing.quantity += quantity;
} else {
items.push({ ...product, quantity });
}
return calculateTotal();
},
removeItem(id) {
items = items.filter(item => item.id !== id);
return calculateTotal();
},
getTotal() {
return total;
},
getItems() {
// Return a copy to prevent external mutation
return [...items];
},
clear() {
items = [];
total = 0;
}
};
})();
// Usage
ShoppingCart.addItem({ id: 1, name: 'Laptop', price: 999 });
ShoppingCart.addItem({ id: 2, name: 'Mouse', price: 25 }, 2);
console.log(ShoppingCart.getTotal()); // 1049
console.log(ShoppingCart.getItems()); // [...]
// Private members are inaccessible
console.log(ShoppingCart.items); // undefined
console.log(ShoppingCart.calculateTotal); // undefined
Encapsulation
Hide internal implementation details and protect data from external modification.
Namespace
Avoid global scope pollution by containing all functionality within a single object.
Organization
Group related functionality together, making code easier to maintain and understand.
Reusability
Create self-contained units you can lift into another project without dragging globals along.
4. The Observer Pattern
Behavioral PatternWhen a user logs in, the navbar needs to update, analytics wants an event, and the session store needs to save something. If login() calls all three directly, it has to know about all three, and every new feature means editing it. With an Observer, the subject (here, the auth service) just announces "login happened" and anything that cares subscribes. It's the same idea behind DOM events, Node's EventEmitter and RxJS.
The Subject notifies all subscribed Observers when state changes
// EventEmitter - A flexible Observer implementation
class EventEmitter {
constructor() {
this.events = {};
}
// Subscribe to an event
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
// Return unsubscribe function
return () => this.off(event, callback);
}
// Subscribe once
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
// Unsubscribe
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event]
.filter(cb => cb !== callback);
}
// Emit event to all subscribers
emit(event, ...args) {
if (!this.events[event]) return;
this.events[event].forEach(callback => {
callback(...args);
});
}
}
// Usage: User Authentication State
class AuthService extends EventEmitter {
constructor() {
super();
this.user = null;
}
login(userData) {
this.user = userData;
this.emit('login', userData);
this.emit('authStateChange', { isAuthenticated: true, user: userData });
}
logout() {
const previousUser = this.user;
this.user = null;
this.emit('logout', previousUser);
this.emit('authStateChange', { isAuthenticated: false, user: null });
}
}
// Multiple observers listening to auth events
const auth = new AuthService();
// UI updates
auth.on('authStateChange', ({ isAuthenticated, user }) => {
updateNavbar(isAuthenticated, user);
});
// Analytics tracking
auth.on('login', (user) => {
analytics.track('user_login', { userId: user.id });
});
// Session management
auth.on('logout', () => {
localStorage.removeItem('session');
});
The Observer pattern is everywhere in JavaScript:
- DOM Events:
element.addEventListener('click', handler) - React/Vue: State changes trigger component re-renders
- RxJS: Observables and subscriptions
- Node.js: EventEmitter in core modules
- Redux: Store subscribers are notified on state changes
The classic bug here is forgetting to unsubscribe. A React component that subscribes in useEffect without returning the cleanup function adds a new listener every time it mounts. In Node you'll eventually see MaxListenersExceededWarning: Possible EventEmitter memory leak detected once an emitter passes 10 listeners for one event. Raising the limit with setMaxListeners() hides the warning, but the leak is still there. That's why the on() above returns an unsubscribe function.
5. The Strategy Pattern
Behavioral PatternThat checkout function with one if branch per payment provider is the textbook case for Strategy. Each variation (credit card, PayPal, crypto) becomes its own object with the same methods, and the code that runs the checkout just calls pay() on whichever one it's given. Adding a provider means adding a class, not editing a growing conditional.
// Payment Strategy Pattern
// Strategy Interface (implicitly defined in JS)
class PaymentStrategy {
pay(amount) {
throw new Error('pay() must be implemented');
}
validate() {
throw new Error('validate() must be implemented');
}
}
// Concrete Strategies
class CreditCardPayment extends PaymentStrategy {
constructor(cardNumber, cvv, expiry) {
super();
this.cardNumber = cardNumber;
this.cvv = cvv;
this.expiry = expiry;
}
validate() {
return this.cardNumber.length === 16 && this.cvv.length === 3;
}
pay(amount) {
console.log(`Paid $${amount} using Credit Card ending in ${this.cardNumber.slice(-4)}`);
return { success: true, method: 'credit_card' };
}
}
class PayPalPayment extends PaymentStrategy {
constructor(email) {
super();
this.email = email;
}
validate() {
return this.email.includes('@');
}
pay(amount) {
console.log(`Paid $${amount} using PayPal account ${this.email}`);
return { success: true, method: 'paypal' };
}
}
class CryptoPayment extends PaymentStrategy {
constructor(walletAddress, currency = 'BTC') {
super();
this.walletAddress = walletAddress;
this.currency = currency;
}
validate() {
return this.walletAddress.length >= 26;
}
pay(amount) {
console.log(`Paid $${amount} in ${this.currency} to wallet ${this.walletAddress.slice(0, 8)}...`);
return { success: true, method: 'crypto', currency: this.currency };
}
}
// Context - uses the strategy
class PaymentProcessor {
constructor() {
this.strategy = null;
}
setStrategy(strategy) {
this.strategy = strategy;
}
checkout(amount) {
if (!this.strategy) {
throw new Error('Please select a payment method');
}
if (!this.strategy.validate()) {
throw new Error('Invalid payment details');
}
return this.strategy.pay(amount);
}
}
// Usage - strategy can be swapped at runtime
const processor = new PaymentProcessor();
// User selects credit card
processor.setStrategy(new CreditCardPayment('4111111111111111', '123', '12/25'));
processor.checkout(99.99);
// Later, user switches to PayPal
processor.setStrategy(new PayPalPayment('user@email.com'));
processor.checkout(49.99);
6. The Decorator Pattern
Structural PatternSay you want logging, caching and retries on a fetch call. You could write a CachedRetryingLoggedFetcher subclass, or you could wrap the function in small layers, each adding one behavior. That's a Decorator. In JavaScript it's usually just a higher-order function, and it's the same idea behind Express middleware and React higher-order components.
// Function Decorator Pattern
// Base function
function fetchData(url) {
return fetch(url).then(res => res.json());
}
// Decorator: Add logging
function withLogging(fn) {
return async function(...args) {
console.log(`Calling ${fn.name} with:`, args);
const start = Date.now();
try {
const result = await fn(...args);
console.log(`${fn.name} completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`${fn.name} failed:`, error);
throw error;
}
};
}
// Decorator: Add caching
function withCache(fn, ttl = 60000) {
const cache = new Map();
return async function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
console.log('Cache hit!');
return cached.data;
}
const result = await fn(...args);
cache.set(key, { data: result, timestamp: Date.now() });
return result;
};
}
// Decorator: Add retry logic
function withRetry(fn, maxRetries = 3) {
return async function(...args) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn(...args);
} catch (error) {
lastError = error;
console.log(`Attempt ${attempt} failed, retrying...`);
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
throw lastError;
};
}
// Compose decorators - order matters!
const enhancedFetch = withLogging(
withCache(
withRetry(fetchData)
)
);
// Usage - now has logging, caching, and retry!
enhancedFetch('/api/users').then(data => console.log(data));
7. The Proxy Pattern
Structural PatternSometimes you want to intercept reads and writes on an object: reject bad values, log access, or delay an expensive setup until someone actually uses it. A Proxy sits in front of the real object and does exactly that. JavaScript has this built in through the Proxy object, which is also how Vue 3's reactivity system tracks changes. One thing to know: if a set trap returns false, strict-mode code (including ES modules and classes) throws a TypeError, so return true or throw your own error, as below.
// Validation Proxy
function createValidatedObject(target, schema) {
return new Proxy(target, {
set(obj, prop, value) {
const validator = schema[prop];
if (validator) {
const { valid, message } = validator(value);
if (!valid) {
throw new Error(`Invalid ${prop}: ${message}`);
}
}
obj[prop] = value;
return true;
}
});
}
// Define validation schema
const userSchema = {
age(value) {
if (typeof value !== 'number') {
return { valid: false, message: 'must be a number' };
}
if (value < 0 || value > 150) {
return { valid: false, message: 'must be between 0 and 150' };
}
return { valid: true };
},
email(value) {
if (!value.includes('@')) {
return { valid: false, message: 'must be valid email' };
}
return { valid: true };
}
};
// Usage
const user = createValidatedObject({}, userSchema);
user.name = 'John'; // OK - no validator
user.age = 25; // OK - valid age
user.email = 'john@example.com'; // OK - valid email
// user.age = -5; // Error: Invalid age: must be between 0 and 150
// user.email = 'invalid'; // Error: Invalid email: must be valid email
// Lazy Loading Proxy (Virtual Proxy)
function createLazyLoader(expensiveInit) {
let instance = null;
return new Proxy({}, {
get(target, prop) {
if (!instance) {
console.log('Initializing expensive resource...');
instance = expensiveInit();
}
return instance[prop];
}
});
}
const heavyResource = createLazyLoader(() => {
// Expensive initialization
return { data: 'expensive data', process() { return 'processed'; } };
});
// Resource not initialized yet!
console.log('Resource created but not initialized');
// First access triggers initialization
console.log(heavyResource.data); // "Initializing..." then "expensive data"
Pattern Comparison: When to Use What
| Pattern | Category | Use Case | Example |
|---|---|---|---|
| Singleton | Creational | Single shared instance | Configuration, Logging |
| Factory | Creational | Object creation logic | UI Components, API Responses |
| Module | Structural | Encapsulation & namespacing | Libraries, Services |
| Observer | Behavioral | Event-driven updates | State Management, Events |
| Strategy | Behavioral | Interchangeable algorithms | Payment, Sorting, Validation |
| Decorator | Structural | Add behavior dynamically | Middleware, HOCs |
| Proxy | Structural | Control access to objects | Validation, Lazy Loading |
Best Practices and Guidelines
When to Use Design Patterns
- Don't force it: Use patterns when they solve a real problem, not just because they exist
- Start simple: Begin with the simplest solution; refactor to patterns when complexity demands it
- Understand the problem: Make sure you understand why a pattern exists before applying it
- Consider maintenance: Patterns should make code easier to maintain, not harder
- Document your choices: When using patterns, document why you chose them
Common Anti-Patterns to Avoid
- Singleton Abuse: Using Singleton when dependency injection would be better
- Over-engineering: Applying patterns to simple problems that don't need them
- Pattern Mania: Using multiple patterns when one would suffice
- Ignoring Built-ins: Creating custom patterns when JavaScript/frameworks provide solutions
- Tight Coupling: Implementing patterns in ways that increase coupling instead of reducing it
When I'd Reach for a Pattern (and When I Wouldn't)
My rule is to wait for the second or third time a problem shows up. A single if with two payment types doesn't need a Strategy; when the fourth provider arrives and the function is 200 lines long, it does. Refactoring toward a pattern at that point is cheap, and you'll know which parts actually vary.
Trade-offs to Weigh
- Singleton: fine for a logger or connection pool. I'd avoid it for anything holding per-user or per-request state, especially on a server where one process handles many users at once.
- Module (IIFE): in new code, just use ES modules. The IIFE version is worth recognizing, not writing.
- Observer: great for decoupling, but event chains are hard to trace. If you can't tell what happens after
emit('login')without searching the whole repo, a direct function call might be clearer. - Strategy and Factory: in JavaScript, a plain object of functions (
{ card: payByCard, paypal: payByPaypal }) often does the job without any classes. - Decorator: keep the stack shallow. Five wrappers deep, stack traces get noisy and the order of caching vs. retry starts to matter in ways nobody remembers.
- Proxy: convenient for validation and dev tooling, but every property access goes through a trap, so I'd keep it out of hot loops.
Once these seven feel familiar, Command, State and Mediator are worth a look next. They show up less often in everyday JavaScript, but you'll recognize them in undo stacks, form wizards and chat UIs.