Hey 👋
It has been a few years now since I started coding professionally. I've always heard the term "Design Patterns" in conversations but never really tried to learn them. It was only recently when a senior dev reviewed my PR at work and left a comment which said "... try to make this a singleton". That led me to finally do some digging on this topic.
I went through a bunch of resources like crash courses, articles and YouTube videos. To my surprise I found that I have been using some of these patterns unknowingly. I am pretty sure you'll feel the same too. There were definitely some new ideas/patterns which I found useful. In this post I'll try to cover some of the patterns which will surely help you to become a better dev and even arouse your curiosity to learn more such patterns.
What are Design Patterns?
Design patterns are battle-tested blueprints for common software architecture problems. They provide an abstract concept to solve a problem. It's up to the developer to build upon them and come up with the actual solution.
Unlike algorithms, these patterns do not provide clear steps to solve a problem. These are just design ideas that you can extend further according to your needs.
Some of these patterns are listed below.
1. Singleton Pattern
Singleton pattern ensures that a class has only one instance, and that instance is accessible globally throughout the application. The imports of this Singleton all reference the same instance.
class LocalStorage {
constructor() {
if (!LocalStorage.instance) {
LocalStorage.instance = this;
}
return LocalStorage.instance;
}
setItem(key, value) {
// save to some storage
console.log(`Item saved: ${key} - ${value}`)
}
getItem(key) {
console.log(`Getting item ${key} from storage.`);
// get from storage and return
}
}
const store = Object.freeze(new LocalStorage());
export default store;
Implementing your own wrapper around local storage is a good example of Singleton pattern. We'd want to use the same instance app wide.
Here, we create an instance of LocalStorage
and export it. There's no way to create another object of this class. Even if they tried, constructor
would stop them as it checks if an instance already exists or not.
Finally, Object.freeze
is cherry on the cake. It'll prevent any modifications to our object.
💡 Uses:
- Gain global access point to a single instance.
- A singleton is only initialized once when it's requested. Therefore, it saves memory space.
👻 Disadvantages:
- It's hard to write tests for singletons as we cannot create new instances every time. All tests depend on a single global instance.
2. Proxy Pattern
Proxy pattern let's you provide a substitute/wrapper for a target object. Using this pattern one can intercept or redefine all the interactions with the target object.
JavaScript has in-built Proxy
and Reflect
objects to implement this pattern.
const target = {
name: 'John',
age: 82
}
const handler = {
get: (target, key) => {
console.log(`Getting key: ${key}`);
return target[key];
},
set: (target, key, value) => {
console.log(`Setting ${value} on key ${key}`);
target[key] = value;
return true;
}
};
const proxyObject = new Proxy(target, handler);
console.log(proxyObject.name);
In the above code, we provide a target
object and a handler
object to the Proxy
. The handler
object can be used to overwrite implementations of object methods like - get
, set
, has
, deleteProperty
, etc.
Alternatively, if we wish to not modify the behavior of these methods like in the example above, we can use Reflect
. It has the exact same functions available.
const handler = {
get: (target, key) => {
console.log(`Getting key: ${key}`);
return Reflect.get(target, key);
},
set: (target, key, value) => {
console.log(`Setting ${value} on key ${key}`);
return Reflect.set(target, key, value);
}
};
💡 Uses:
- Access control
- Logging requests
- Caching
- Lazy initialization
👻 Disadvantages:
- It could lead to performance issues.
- The code might get complex with the introduction of too many proxies.
3. Observer Pattern
The observer pattern is a design pattern in which an object, called the subject or publisher, maintains a list of its dependents, called observers or subscribers, and notifies them automatically of any changes to its state. This allows multiple objects to be notified when the state of another object changes, without them having a direct reference to one another.
A simple example of this pattern would look something like this.
class Button {
constructor() {
this.subscribers = [];
}
subscribe(fn) {
this.subscribers.push(fn);
}
doSomething() {
const data = { x: 1 };
this.notify(data);
}
notify(data) {
this.subscribers.forEach(fn => fn(data));
}
}
class EventHandler {
listener(data) {
console.log("Received:", data);
}
}
const button = new Button();
const eventHandler = new EventHandler();
button.subscribe(eventHandler.listener);
button.doSomething();
// Received: {x : 1}
In this example Button
keeps track of all its subscribers and notifies them of any change by calling notify
method.
💡 Uses:
- This pattern can be used when change in state of one object is important for other objects and these objects are not known beforehand.
👻 Disadvantages:
- This might lead to some performance issues in case of too many subscribers or some complex code in the
notify
function. - The order in which these subscribers get notified cannot be predicted.
4. Factory Pattern
The factory pattern is a design pattern that provides a way to create objects without specifying the exact class of object that will be created. Instead, a factory class/function is responsible for creating the objects.
IMO, This is the simplest of all the patterns discussed here. There's a high chance that many of you are already using it.
class Car {
constructor(make, model) {
this.make = make;
this.model = model;
}
}
class Truck {
constructor(make, model, payloadCapacity) {
this.make = make;
this.model = model;
this.payloadCapacity = payloadCapacity;
}
}
function vehicleFactory(type, make, model, payloadCapacity) {
switch (type) {
case "car":
return new Car(make, model);
case "truck":
return new Truck(make, model, payloadCapacity);
default:
throw new Error(`Invalid vehicle type: ${type}`);
}
}
const car = vehicleFactory("car", "Toyota", "Camry");
console.log(car);
// Output: Car { make: "Toyota", model: "Camry" }
const truck = vehicleFactory("truck", "Ford", "F-150", 2000);
console.log(truck);
// Output: Truck { make: "Ford", model: "F-150", payloadCapacity: 2000 }
Instead of accessing the Car
or Truck
classes directly we delegate that task to our factory function.
💡 Uses:
- Can be used almost anywhere without any sort of overhead.
- Enforces DRY.
👻 Disadvantages: I don't see any! 👀
5. Prototype Pattern
The prototype pattern is a design pattern that allows objects to be cloned or copied, rather than created from scratch. The Prototype design pattern relies on the JavaScript prototypical inheritance. This pattern is used mainly for creating objects in performance-intensive situations.
An example of the prototype pattern in JavaScript is a prototype object that represents a basic shape, such as a rectangle, and a function that creates new shapes based on the prototype.
const shapePrototype = {
width: 0,
height: 0,
area: function() {
return this.width * this.height;
}
};
function createShape(shape) {
const newShape = Object.create(shapePrototype);
newShape.width = shape.width;
newShape.height = shape.height;
return newShape;
}
const shape1 = createShape({width: 5, height: 10});
console.log(shape1.area());
// Output: 50
const shape2 = createShape({width: 3, height: 4});
console.log(shape2.area());
// Output: 12
The createShape
function takes an object as an argument, which is used to set the width
and height
properties of the new shape. The new shape object inherits the area
method from the prototype.
You can verify this by checking the prototypes of the objects.
console.log(shapePrototype.__proto__)
// This is for you to find out! ;)
console.log(shape1.__proto__)
// Output: {
// area: function() {
// return this.width * this.height;
// },
// height: 0,
// width: 0
// }
console.log(shape1.__proto__.__proto__ === shapePrototype.__proto__)
// Output: true
💡 Uses:
- Memory efficient as multiple objects rely on the same prototype and share the same methods or properties.
👻 Disadvantages:
- In case of a long prototype chain, it's hard to know where certain properties come from.
In conclusion, design patterns are a powerful tool for organizing and structuring your code, making it more readable, maintainable, and reusable. The five patterns covered in this article are just a few examples of the many design patterns available in JavaScript. By understanding these patterns and how they can be applied, you can improve the quality of your code and make it more robust and efficient.
I hope this article has been helpful and informative. I will continue to write about other design patterns in the future and feel free to connect with me on LinkedIn and Twitter, where I share my thoughts and updates about my work and articles.
Thank you! 🙏
Top comments (0)