DEV Community

Cover image for JavaScript Design Patterns: A Beginner's Guide to Better Code
Sanjeev Sharma
Sanjeev Sharma

Posted on • Edited on

JavaScript Design Patterns: A Beginner's Guide to Better Code

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Gain global access point to a single instance.
  2. A singleton is only initialized once when it's requested. Therefore, it saves memory space.

👻 Disadvantages:

  1. 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);
Enter fullscreen mode Exit fullscreen mode

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);
 }
};
Enter fullscreen mode Exit fullscreen mode

💡 Uses:

  1. Access control
  2. Logging requests
  3. Caching
  4. Lazy initialization

👻 Disadvantages:

  1. It could lead to performance issues.
  2. 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}
Enter fullscreen mode Exit fullscreen mode

In this example Button keeps track of all its subscribers and notifies them of any change by calling notify method.

💡 Uses:

  1. 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:

  1. This might lead to some performance issues in case of too many subscribers or some complex code in the notify function.
  2. 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 }
Enter fullscreen mode Exit fullscreen mode

Instead of accessing the Car or Truck classes directly we delegate that task to our factory function.

💡 Uses:

  1. Can be used almost anywhere without any sort of overhead.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 Uses:

  1. Memory efficient as multiple objects rely on the same prototype and share the same methods or properties.

👻 Disadvantages:

  1. 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)