JavaScript Design Patterns : The Ultimate Guide
JavaScript, with its widespread adoption and versatility, has become a cornerstone of modern web development.
What Is JavaScript?
JavaScript is a lightweight, interpreted, object-oriented programming language with first-class functions most commonly known as a scripting language for web pages.
The above definition means that JavaScript code has a low memory footprint, easy to implement and easy to learn, with a syntax similar to popular languages such as C++ and Java. It is a scripting language, which means that its code is interpreted instead of compiled. It has support for procedural, object-oriented, and functional programming styles, which makes it very flexible for developers.
Prerequisite
To understand the concepts and techniques discussed in this article, you are expected to have an understanding of the fundamentals of JavaScript. Familiarity with concepts like variables, functions, data types, object-oriented programming, etc. is essential.
What is A Design Pattern in JavaScript?
These patterns are not algorithms or specific implementations. They are more like ideas, opinions, and abstractions that can be useful in certain situations to solve a particular kind of problem.
The specific implementation of the patterns may vary depending on many different factors. But what’s important is the concepts behind them, and how they might help us achieve a better solution for our problem.
Importance of patterns in JavaScript development
During any language’s lifespan, many such reusable solutions are made and tested by a large number of developers from that language’s community. It is because of this combined experience of many developers that such solutions are so useful because they help us write code in an optimized way while at the same time solving the problem at hand.
The main benefits we get from design patterns are the following:
- They are proven solutions: Because design patterns are often used by many developers, you can be certain that they work. And not only that, you can be certain that they were revised multiple times and optimizations were probably implemented.
- They are easily reusable: Design patterns document a reusable solution which can be modified to solve multiple particular problems, as they are not tied to a specific problem.
- They are expressive: Design patterns can explain a large solution quite elegantly.
- They ease communication: When developers are familiar with design patterns, they can more easily communicate with one another about potential solutions to a given problem.
- They prevent the need for refactoring code: If an application is written with design patterns in mind, it is often the case that you won’t need to refactor the code later on because applying the correct design pattern to a given problem is already an optimal solution.
- They lower the size of the codebase: Because design patterns are usually elegant and optimal solutions, they usually require less code than other solutions.
Design Pattern Categorization
Design patterns can be categorized in multiple ways, but the most popular one is the following:
- Creational design patterns
- Structural design patterns
- Behavioral design patterns
- Concurrency design patterns
- Architectural design patterns
Creational Design Patterns
These patterns deal with object creation mechanisms which optimize object creation compared to a basic approach. The basic form of object creation could result in design problems or in added complexity to the design. Creational design patterns solve this problem by somehow controlling object creation. Some of the popular design patterns in this category are:
- Factory method
- Abstract factory
- Builder
- Prototype
- Singleton
Structural Design Patterns
These patterns deal with object relationships. They ensure that if one part of a system changes, the entire system doesn’t need to change along with it. The most popular patterns in this category are:
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
Behavioral Design Patterns
These types of patterns recognize, implement, and improve communication between disparate objects in a system. They help ensure that disparate parts of a system have synchronized information. Popular examples of these patterns are:
- Chain of responsibility
- Command
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Visitor
Concurrency Design Patterns
These types of design patterns deal with multi-threaded programming paradigms. Some of the popular ones are:
- Active object
- Nuclear reaction
- Scheduler
Architectural Design Patterns
Design patterns which are used for architectural purposes. Some of the most famous ones are:
- MVC (Model-View-Controller)
- MVP (Model-View-Presenter)
- MVVM (Model-View-ViewModel)
In the following section, we are going to take a closer look at some of the aforementioned design patterns with examples provided for better understanding.
Design Pattern Examples
1. Singleton Pattern
The singleton pattern is used in scenarios when we need exactly one instance of a class. For example, we need to have an object which contains some configuration for something. In these cases, it is not necessary to create a new object whenever the configuration object is required somewhere in the system.
var singleton = (function() {
// private singleton value which gets initialized only once
var config;
function initializeConfiguration(values){
this.randomNumber = Math.random();
values = values || {};
this.number = values.number || 5;
this.size = values.size || 10;
}
// we export the centralized method for retrieving the singleton value
return {
getConfig: function(values) {
// we initialize the singleton value only once
if (config === undefined) {
config = new initializeConfiguration(values);
}
// and return the same config value wherever it is asked for
return config;
}
};
})();
var configObject = singleton.getConfig({ "size": 8 });
// prints number: 5, size: 8, randomNumber: someRandomDecimalValue
console.log(configObject);
var configObject1 = singleton.getConfig({ "number": 8 });
// prints number: 5, size: 8, randomNumber: same randomDecimalValue as in first config
console.log(configObject1);
2. Factory Pattern
The Factory Pattern provides a way to create objects without specifying their concrete classes. It encapsulates the object creation logic in a separate factory method, allowing flexibility and decoupling between the creator and the created objects.
// Implementation example of the Factory Pattern
class Car {
constructor(make, model) {
this.make = make;
this.model = model;
}
}
class CarFactory {
createCar(make, model) {
return new Car(make, model);
}
}
const factory = new CarFactory();
const myCar = factory.createCar("Tope", "Model 1");
In this example, a CarFactory
instance is created using new CarFactory()
, and then the createCar
method is invoked on the factory with the arguments "Tope" and "Model 1". This creates a new Car object with the make "Tope" and model "Model 1", which is assigned to the myCar
variable.
3. Constructor Pattern
The Constructor Pattern creates objects from a constructor function using the new
keyword. It allows you to define and initialize object properties within the constructor function.
// Implementation example of the Constructor Pattern
function Person(name, age) {
this.name = name;
this.age = age;
}
const tope = new Person("Tope", 24);
The above code defines a constructor function called Person that takes two parameters: name and age. Inside the function, the name and age values are assigned to the respective properties of the newly created object using the this keyword.
Later, a new instance of the Person object is created by invoking the Person function with the arguments “Tope” and 24. This creates a new object with the name property set to “Tope” and the age property set to 24, which is then assigned to the variable tope. The output of this code is that Tope holds an object representing a person with the name “Tope” and the age of 24.
4. Builder Pattern
In the Builder pattern, a builder class or object is responsible for constructing the final object. It provides a set of methods to configure and set the properties of the object being built. The construction process typically involves invoking these methods in a specific order to gradually build the object.
class CarBuilder {
constructor() {
this.car = new Car();
}
setMake(make) {
this.car.make = make;
return this;
}
setModel(model) {
this.car.model = model;
return this;
}
setEngine(engine) {
this.car.engine = engine;
return this;
}
setWheels(wheels) {
this.car.wheels = wheels;
return this;
}
build() {
return this.car;
}
}
class Car {
constructor() {
this.make = "";
this.model = "";
this.engine = "";
this.wheels = 0;
}
displayInfo() {
console.log(`Make: ${this.make}, Model: ${this.model}, Engine: ${this.engine}, Wheels: ${this.wheels}`);
}
}
// Usage
const carBuilder = new CarBuilder();
const car = carBuilder.setMake("Toyota").setModel("Camry").setEngine("V6").setWheels(4).build();
car.displayInfo(); // Output: Make: Toyota, Model: Camry, Engine: V6, Wheels: 4
In this example, the CarBuilder
class allows for the construction of Car objects with different properties. By calling setMake
, setModel
, setEngine
, setWheels
methods, the properties of the Car object are set. The build method finalizes the construction and returns the fully built Car object. The Car class represents a car and includes a displayInfo
method to log its details. By creating a carBuilder
instance and chaining the property-setting methods, a car object is constructed with specific make, model, engine, and wheel values. Invoking car.displayInfo()
displays the car's information.
5. Prototype Pattern
The Prototype pattern in JavaScript focuses on creating objects by cloning or extending existing objects as prototypes. It allows us to create new instances without explicitly defining their classes. In this pattern, objects act as prototypes for creating new objects, enabling inheritance and the sharing of properties and methods among multiple objects.
// Prototype object
const carPrototype = {
wheels: 4,
startEngine() {
console.log("Engine started.");
},
stopEngine() {
console.log("Engine stopped.");
}
};
// Create new car instance using the prototype
const car1 = Object.create(carPrototype);
car1.make = "Toyota";
car1.model = "Camry";
// Create another car instance using the same prototype
const car2 = Object.create(carPrototype);
car2.make = "Honda";
car2.model = "Accord";
car1.startEngine(); // Output: "Engine started."
car2.stopEngine(); // Output: "Engine stopped."
In this example, car instances car1 and car2 are created using a prototype object carPrototype. car1 has the make “Toyota” and model “Camry”, while car2 has the make “Honda” and model “Accord”. When car1.startEngine()
is called, it outputs "Engine started.", and when car2.stopEngine()
is called, it outputs "Engine stopped.". This demonstrates the utilization of a prototype object to share properties and methods among multiple instances.
6. Module Pattern
The Module Pattern encapsulates related methods and properties into a single module, providing a clean way to organize and protect the code. It allows for private and public members, enabling information hiding and preventing global namespace pollution.
const MyModule = (function() {
// Private members
let privateVariable = "I am private";
function privateMethod() {
console.log("This is a private method");
}
// Public members
return {
publicVariable: "I am public",
publicMethod() {
console.log("This is a public method");
// Accessing private members within the module
console.log(privateVariable);
privateMethod();
}
};
})();
// Usage
console.log(MyModule.publicVariable); // Output: "I am public"
MyModule.publicMethod(); // Output: "This is a public method" "I am private" "This is a private method"
In this example, the code uses an immediately invoked function expression (IIFE) to encapsulate private and public members. The module has private variables and methods, as well as public variables and methods. When accessed, the public members provide the expected output. This pattern allows for controlled access to encapsulated private members while exposing selected public members.
7. Decorator Pattern
The Decorator Pattern allows you to add behavior or modify the existing behavior of an object dynamically. It enhances the functionality of an object by wrapping it with one or more decorators without modifying its structure.
// Implementation example of the Decorator Pattern
class Coffee {
getCost() {
return 1;
}
}
class CoffeeDecorator {
constructor(coffee) {
this.coffee = coffee;
}
getCost() {
return this.coffee.getCost() + 0.5;
}
}
const myCoffee = new Coffee();
const coffeeWithMilk = new CoffeeDecorator(myCoffee);
console.log(coffeeWithMilk.getCost()); // Output: 1.5
In this example, the CoffeeDecorator
class wraps a base Coffee
object and adds additional functionality. It has a getCost
method that calculates the total cost by combining the cost of the base coffee with an additional cost of 0.5.
In the usage section, a myCoffee
instance of the Coffee
class is created. Then, a coffeeWithMilk
instance of the CoffeeDecorator
class is instantiated, passing myCoffee
as an argument. When coffeeWithMilk.getCost()
is called, it returns the total cost of the coffee with the added cost from the decorator, resulting in an output of 1.5. This example illustrates how the decorator pattern can extend the functionality of an object by dynamically adding or modifying its properties or methods.
8. Facade Pattern
The Facade Pattern provides a simplified interface to a complex subsystem, acting as a front-facing interface that hides the underlying implementation details. It offers a convenient way to interact with a complex system by providing a high-level interface.
// Implementation example of the Facade Pattern
class SubsystemA {
operationA() {
console.log("Subsystem A operation.");
}
}
class SubsystemB {
operationB() {
console.log("Subsystem B operation.");
}
}
class Facade {
constructor() {
this.subsystemA = new SubsystemA();
this.subsystemB = new SubsystemB();
}
operation() {
this.subsystemA.operationA();
this.subsystemB.operationB();
}
}
const facade = new Facade();
facade.operation(); // Output: "Subsystem A operation." "Subsystem B operation."
In this example, the code consists of three classes: SubsystemA
, SubsystemB
, and Facade
. The SubsystemA
and SubsystemB
classes represent independent subsystems and have their respective operationA
and operationB
methods. The Facade
class serves as a simplified interface that aggregates the functionality of the subsystems.
In the usage section, a facade
instance of the Facade
class is created. Invoking facade.operation()
triggers the execution of operationA
from SubsystemA
and operationB
from SubsystemB
. As a result, the output displays "Subsystem A operation." followed by "Subsystem B operation." This demonstrates how the Facade pattern provides a unified and simplified interface to interact with complex subsystems, abstracting their complexities and making them easier to use.
9. Adapter Pattern
The Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate by acting as a bridge between them. It provides a way to convert the interface of one object into another interface that clients expect.
// Implementation
class LegacyPrinter {
printLegacy(text) {
console.log(`Legacy Printing: ${text}`);
}
}
// Target interface
class Printer {
print(text) {}
}
// Adapter
class PrinterAdapter extends Printer {
constructor() {
super();
this.legacyPrinter = new LegacyPrinter();
}
print(text) {
this.legacyPrinter.printLegacy(text);
}
}
// Usage
const printer = new PrinterAdapter();
printer.print("Hello, World!"); // Output: "Legacy Printing: Hello, World!
In this code, the Adapter pattern is used to bridge the gap between the LegacyPrinter
class and a desired Printer
interface. The PrinterAdapter
extends the Printer
class and internally utilizes the LegacyPrinter
to adapt the print
method. When printer.print("Hello, World!")
is called, it effectively triggers the legacy printing functionality with the output "Legacy Printing: Hello, World!". This shows how the Adapter pattern enables the integration of incompatible components by providing a standardized interface.
10. Bridge Pattern
The Bridge pattern is a structural design pattern that separates the abstraction and implementation of a system, allowing it to evolve independently. It introduces a bridge between the two by using an interface or abstract class. Here’s an example code snippet to illustrate the Bridge pattern:
// Example
class Shape {
constructor(color) {
this.color = color;
}
draw() {}
}
// Concrete Abstractions
class Circle extends Shape {
draw() {
console.log(`Drawing a ${this.color} circle`);
}
}
class Square extends Shape {
draw() {
console.log(`Drawing a ${this.color} square`);
}
}
// Implementor
class Color {
getColor() {}
}
// Concrete Implementors
class RedColor extends Color {
getColor() {
return "red";
}
}
class BlueColor extends Color {
getColor() {
return "blue";
}
}
// Usage
const redCircle = new Circle(new RedColor());
redCircle.draw(); // Output: "Drawing a red circle"
const blueSquare = new Square(new BlueColor());
blueSquare.draw(); // Output: "Drawing a blue square"
In this example, we have the Abstraction represented by the Shape class, which has a color property and a draw method. The Concrete Abstractions, Circle and Square, inherit from the Shape class and implement their specific draw behavior. The Implementor
is represented by the Color class, which declares the getColor
method. The Concrete Implementors
, RedColor
, and BlueColor
, inherit from the Color class and provide their respective color implementations.
In the usage section, we create instances of the Concrete Abstractions, passing the appropriate Concrete Implementor objects. This allows the Abstraction to delegate the color-related functionality to the Implementor. When we invoke the draw method, it accesses the color from the Implementor and performs the drawing operation accordingly.
11. Composite Patterns
The Composite pattern is a structural design pattern that allows you to treat individual objects and compositions of objects uniformly. It enables you to create hierarchical structures where each element can be treated as a single object or a collection of objects. The pattern uses a common interface to represent both individual objects (leaf nodes) and compositions (composite nodes), allowing clients to interact with them uniformly.
// Implementation
class Employee {
constructor(name) {
this.name = name;
}
print() {
console.log(`Employee: ${this.name}`);
}
}
// Composite
class Manager extends Employee {
constructor(name) {
super(name);
this.employees = [];
}
add(employee) {
this.employees.push(employee);
}
remove(employee) {
const index = this.employees.indexOf(employee);
if (index !== -1) {
this.employees.splice(index, 1);
}
}
print() {
console.log(`Manager: ${this.name}`);
for (const employee of this.employees) {
employee.print();
}
}
}
// Usage
const john = new Employee("John Doe");
const jane = new Employee("Jane Smith");
const mary = new Manager("Mary Johnson");
mary.add(john);
mary.add(jane);
const peter = new Employee("Peter Brown");
const bob = new Manager("Bob Williams");
bob.add(peter);
bob.add(mary);
bob.print();
In this example, we have the Component class Employee, which represents individual employees. The Composite class Manager extends the Employee class and can contain a collection of employees. It provides methods to add and remove employees from the collection and overrides the print method to display the manager’s name and the employees under them.
In the usage section, we create a composite hierarchy where Manager objects can contain both individual employees (Employee) and other managers (Manager). We add employees to managers, constructing a hierarchical structure. Finally, we invoke the print method on the top-level manager, which recursively prints the hierarchy, showing the managers and their respective employees.
12. Observer Pattern
The Observer Pattern establishes a one-to-many relationship between objects, where multiple observers are notified of changes in the subject’s state. It enables loose coupling between objects and promotes event-driven communication.
// Implementation example of the Observer Pattern
class Subject {
constructor() {
this.observers = [];
}
addObserver(observer) {
this.observers.push(observer);
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notifyObservers() {
this.observers.forEach((observer) => observer.update());
}
}
class Observer {
update() {
console.log("Observer is notified of changes.");
}
}
const subject = new Subject();
const observer1 = new Observer();
const observer2 = new Observer();
subject.addObserver(observer1);
subject.addObserver(observer2);
subject.notifyObservers(); // Output: "Observer is notified of changes." "Observer is notified of changes."
In this example, the Subject
class represents a subject that maintains a list of observers and provides methods to add, remove, and notify observers. The Observer
class defines the behavior of an observer with its update
method. In the usage section, a subject
instance of the Subject
class is created. Two observer
instances are also created and added to the subject using the addObserver
method.
When subject.notifyObservers()
is invoked, it triggers the update
method for each observer. As a result, the output "Observer is notified of changes." is logged twice, indicating that the observers have been notified of the changes in the subject.
13. Strategy Pattern
The Strategy Pattern allows you to encapsulate interchangeable algorithms within separate strategy objects. It enables dynamic selection of algorithms at runtime, promoting flexibility and extensibility.
// Implementation example of the Strategy Pattern
class Context {
constructor(strategy) {
this.strategy = strategy;
}
executeStrategy() {
this.strategy.execute();
}
}
class ConcreteStrategyA {
execute() {
console.log("Strategy A is executed.");
}
}
class ConcreteStrategyB {
execute() {
console.log("Strategy B is executed.");
}
}
const contextA = new Context(new ConcreteStrategyA());
contextA.executeStrategy(); // Output: "Strategy A is executed."
const contextB = new Context(new ConcreteStrategyB());
contextB.executeStrategy(); // Output: "Strategy B is executed."
In this example, the Context
class represents a context that encapsulates different strategies, with a strategy
property and an executeStrategy
method. There are two concrete strategy classes, ConcreteStrategyA
and ConcreteStrategyB
, each with its own execute
method that outputs a specific message.
In the usage section, a contextA
instance of the Context
class is created with ConcreteStrategyA
as the strategy. Calling contextA.executeStrategy()
invokes the execute
method of ConcreteStrategyA
, resulting in the output "Strategy A is executed." Similarly, a contextB
instance is created with ConcreteStrategyB
as the strategy, and invoking contextB.executeStrategy()
triggers the execute
method of ConcreteStrategyB
, resulting in the output "Strategy B is executed." This demonstrates how the Strategy pattern allows for dynamic selection of behavior at runtime by encapsulating it in different strategy objects.
14. Command Pattern
The Command Pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue or log requests, and support undo operations. It decouples the sender of a request from the receiver, promoting loose coupling and flexibility.
// Implementation
class Receiver {
execute() {
console.log("Receiver executes the command.");
}
}
class Command {
constructor(receiver) {
this.receiver = receiver;
}
execute() {
this.receiver.execute();
}
}
class Invoker {
setCommand(command) {
this.command = command;
}
executeCommand() {
this.command.execute();
}
}
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker();
invoker.setCommand(command);
invoker.executeCommand(); // Output: "Receiver executes the command."
In this example, the Receiver
class executes the command when called, and the Command
class encapsulates a command and delegates execution to the receiver. The Invoker
class sets and executes a command. In the usage section, a receiver, command, and invoker are created. The command is set for the invoker, and invoking invoker.executeCommand()
executes the command, resulting in the output "Receiver executes the command."
15. Iterator Pattern
The Iterator pattern is a behavioral design pattern that provides a way to access the elements of an aggregate object sequentially without exposing its underlying representation. It allows you to traverse a collection of objects in a uniform manner, regardless of the specific implementation of the collection. The pattern separates the traversal logic from the collection, promoting a clean and flexible approach to iterating over elements.
// Implementation
class Collection {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
createIterator() {}
}
// Concrete Aggregate
class ConcreteCollection extends Collection {
createIterator() {
return new ConcreteIterator(this);
}
}
// Iterator
class Iterator {
constructor(collection) {
this.collection = collection;
this.index = 0;
}
hasNext() {}
next() {}
}
// Concrete Iterator
class ConcreteIterator extends Iterator {
hasNext() {
return this.index < this.collection.items.length;
}
next() {
return this.collection.items[this.index++];
}
}
// Usage
const collection = new ConcreteCollection();
collection.addItem("Item 1");
collection.addItem("Item 2");
collection.addItem("Item 3");
const iterator = collection.createIterator();
while (iterator.hasNext()) {
console.log(iterator.next());
}
In this code, we have the Aggregate represented by the Collection class, which defines the interface for creating an iterator object. The Concrete Aggregate, ConcreteCollection
, extends the Collection class and provides a concrete implementation of the iterator creation.
The Iterator is represented by the Iterator class, which defines the interface for accessing and traversing elements. The Concrete Iterator, ConcreteIterator
, extends the Iterator class and provides a concrete implementation of the iteration logic. In the usage section, we create an instance of the Concrete Aggregate, ConcreteCollection
, and add items to it. We then create an iterator using the createIterator
method. By using the iterator's hasNext
and next methods, we iterate over the collection and print each item.
16. Mediator Pattern
The Mediator pattern simplifies object communication by introducing a mediator object that serves as a central hub for coordinating interactions between objects. It encapsulates the communication logic and provides methods for objects to register, send, and receive messages.
// Implementation
class Mediator {
constructor() {
this.colleague1 = null;
this.colleague2 = null;
}
setColleague1(colleague) {
this.colleague1 = colleague;
}
setColleague2(colleague) {
this.colleague2 = colleague;
}
notifyColleague1(message) {
this.colleague1.receive(message);
}
notifyColleague2(message) {
this.colleague2.receive(message);
}
}
class Colleague {
constructor(mediator) {
this.mediator = mediator;
}
send(message) {
// Send a message to the mediator
this.mediator.notifyColleague2(message);
}
receive(message) {
console.log(`Received message: ${message}`);
}
}
// Usage
const mediator = new Mediator();
const colleague1 = new Colleague(mediator);
const colleague2 = new Colleague(mediator);
mediator.setColleague1(colleague1);
mediator.setColleague2(colleague2);
colleague1.send("Hello Colleague 2!"); // Output: "Received message: Hello Colleague 2!"
In this example, we have a Mediator class that acts as an intermediary between two Colleague objects. The Mediator holds references to the colleagues and provides methods to send messages between them.
Each Colleague object has a reference to the mediator and can send messages by notifying the mediator. The mediator, in turn, relays the messages to the appropriate colleagues. In this case, Colleague 1 sends a message to Colleague 2, and the latter receives and logs the message.
Conclusion
Design patterns are a very useful tool which any senior JavaScript developer should be aware of. Knowing the specifics regarding design patterns could prove incredibly useful and save you a lot of time in any project’s lifecycle, especially the maintenance part. Modifying and maintaining systems written with the help of design patterns which are a good fit for the system’s needs could prove invaluable.
By leveraging these design patterns, JavaScript developers can improve code reusability, maintainability, and overall system performance. Armed with this knowledge, we can architect robust and efficient JavaScript applications that meet the demands of modern software development.
Platform:
Website : https://victorpreston.tech
Thanks: