JavaScript design patterns help solve common problems in app building by providing a repeatable structure. Basically, they're a lifesaver for JavaScript development.
In this guide, we'll dive into what they are and how to use them.
Let’s get started!
What Is a JavaScript Design Pattern?
JavaScript design patterns are repeatable template solutions for frequently occurring problems in JavaScript app development.
The idea is simple: Programmers all around the world, since the dawn of development, have faced sets of recurring issues when developing apps. Over time, some developers chose to document tried and tested ways to tackle these issues so others could refer back to the solutions with ease.
As more and more developers chose to use these solutions and recognized their efficiency in solving their problems, they became accepted as a standard way of problem-solving and were given the name “design patterns.”
Elements of a Design Pattern
Almost all design patterns can be broken down into a set of four important components. They are:
Pattern name: This is used to identify a design pattern while communicating with other users. Examples include “singleton,” “prototype,” and more.
Problem: This describes the aim of the design pattern. It’s a small description of the issue that the design pattern is trying to solve. It can even include an example scenario to better explain the issue. It can also contain a list of conditions to be met for a design pattern to fully solve the underlying issue.
Solution: This is the solution to the problem at hand, made up of elements like classes, methods, interfaces, etc. It’s where the bulk of a design pattern lies — it entails relationships, responsibilities, and collaborators of various elements that are clearly defined.
Results: This is an analysis of how well the pattern was able to solve the problem. Things like space and time usage are discussed, along with alternative approaches to solving the same problem.
If you’re looking to learn more about design patterns and their inception, MSU has some succinct study material that you can refer to.
Top 5 Creational JavaScript Design Patterns To Master
Now that you understand what a design pattern is made of and why you need them, let’s take a deeper dive into how some of the most commonly used JavaScript design patterns can be implemented in a JavaScript app.
Let’s start the discussion with some fundamental, easy-to-learn creational design patterns.
1. Singleton
The Singleton pattern is one of the most commonly used design patterns across the software development industry. The problem that it aims to solve is to maintain only a single instance of a class. This can come in handy when instantiating objects that are resource-intensive, such as database handlers.
Here’s how you can implement it in JavaScript:
function SingletonFoo() {
let fooInstance = null;
// For our reference, let's create a counter that will track the number of active instances
let count = 0;
function printCount() {
console.log("Number of instances: " + count);
}
function init() {
// For our reference, we'll increase the count by one whenever init() is called
count++;
// Do the initialization of the resource-intensive object here and return it
return {}
}
function createInstance() {
if (fooInstance == null) {
fooInstance = init();
}
return fooInstance;
}
function closeInstance() {
count--;
fooInstance = null;
}
return {
initialize: createInstance,
close: closeInstance,
printCount: printCount
}
}
let foo = SingletonFoo();
foo.printCount() // Prints 0
foo.initialize()
foo.printCount() // Prints 1
foo.initialize()
foo.printCount() // Still prints 1
foo.initialize()
foo.printCount() // Still 1
foo.close()
foo.printCount() // Prints 0
While it serves the purpose well, the Singleton pattern is known to make debugging difficult since it masks dependencies and controls the access to initializing or destroying a class’s instances.
2. Factory
The Factory method is also one of the most popular design patterns. The problem that the Factory method aims to solve is creating objects without using the conventional constructor. Instead, it takes in the configuration (or description) of the object that you want and returns the newly created object.
Here’s how you can implement it in JavaScript:
function Factory() {
this.createDog = function (breed) {
let dog;
if (breed === "labrador") {
dog = new Labrador();
} else if (breed === "bulldog") {
dog = new Bulldog();
} else if (breed === "golden retriever") {
dog = new GoldenRetriever();
} else if (breed === "german shepherd") {
dog = new GermanShepherd();
}
dog.breed = breed;
dog.printInfo = function () {
console.log("\n\nBreed: " + dog.breed + "\nShedding Level (out of 5): " + dog.sheddingLevel + "\nCoat Length: " + dog.coatLength + "\nCoat Type: " + dog.coatType)
}
return dog;
}
}
function Labrador() {
this.sheddingLevel = 4
this.coatLength = "short"
this.coatType = "double"
}
function Bulldog() {
this.sheddingLevel = 3
this.coatLength = "short"
this.coatType = "smooth"
}
function GoldenRetriever() {
this.sheddingLevel = 4
this.coatLength = "medium"
this.coatType = "double"
}
function GermanShepherd() {
this.sheddingLevel = 4
this.coatLength = "medium"
this.coatType = "double"
}
function run() {
let dogs = [];
let factory = new Factory();
dogs.push(factory.createDog("labrador"));
dogs.push(factory.createDog("bulldog"));
dogs.push(factory.createDog("golden retriever"));
dogs.push(factory.createDog("german shepherd"));
for (var i = 0, len = dogs.length; i < len; i++) {
dogs[i].printInfo();
}
}
run()
/**
Output:
Breed: labrador
Shedding Level (out of 5): 4
Coat Length: short
Coat Type: double
Breed: bulldog
Shedding Level (out of 5): 3
Coat Length: short
Coat Type: smooth
Breed: golden retriever
Shedding Level (out of 5): 4
Coat Length: medium
Coat Type: double
Breed: german shepherd
Shedding Level (out of 5): 4
Coat Length: medium
Coat Type: double
*/
The Factory design pattern controls how the objects will be created and provides you with a quick way of creating new objects, as well as a uniform interface that defines the properties that your objects will have. You can add as many dog breeds as you want, but as long as the methods and properties exposed by the breed types remain the same, they will work flawlessly.
However, note that the Factory pattern can often lead to a large number of classes that can be difficult to manage.
3. Abstract Factory
The Abstract Factory method takes the Factory method up a level by making factories abstract and thus replaceable without the calling environment knowing the exact factory used or its internal workings. The calling environment only knows that all the factories have a set of common methods that it can call to perform the instantiation action.
This is how it can be implemented using the previous example:
// A factory to create dogs
function DogFactory() {
// Notice that the create function is now createPet instead of createDog, since we need
// it to be uniform across the other factories that will be used with this
this.createPet = function (breed) {
let dog;
if (breed === "labrador") {
dog = new Labrador();
} else if (breed === "pug") {
dog = new Pug();
}
dog.breed = breed;
dog.printInfo = function () {
console.log("\n\nType: " + dog.type + "\nBreed: " + dog.breed + "\nSize: " + dog.size)
}
return dog;
}
}
// A factory to create cats
function CatFactory() {
this.createPet = function (breed) {
let cat;
if (breed === "ragdoll") {
cat = new Ragdoll();
} else if (breed === "singapura") {
cat = new Singapura();
}
cat.breed = breed;
cat.printInfo = function () {
console.log("\n\nType: " + cat.type + "\nBreed: " + cat.breed + "\nSize: " + cat.size)
}
return cat;
}
}
// Dog and cat breed definitions
function Labrador() {
this.type = "dog"
this.size = "large"
}
function Pug() {
this.type = "dog"
this.size = "small"
}
function Ragdoll() {
this.type = "cat"
this.size = "large"
}
function Singapura() {
this.type = "cat"
this.size = "small"
}
function run() {
let pets = [];
// Initialize the two factories
let catFactory = new CatFactory();
let dogFactory = new DogFactory();
// Create a common petFactory that can produce both cats and dogs
// Set it to produce dogs first
let petFactory = dogFactory;
pets.push(petFactory.createPet("labrador"));
pets.push(petFactory.createPet("pug"));
// Set the petFactory to produce cats
petFactory = catFactory;
pets.push(petFactory.createPet("ragdoll"));
pets.push(petFactory.createPet("singapura"));
for (var i = 0, len = pets.length; i < len; i++) {
pets[i].printInfo();
}
}
run()
/**
Output:
Type: dog
Breed: labrador
Size: large
Type: dog
Breed: pug
Size: small
Type: cat
Breed: ragdoll
Size: large
Type: cat
Breed: singapura
Size: small
*/
The Abstract Factory pattern makes it easy for you to exchange concrete factories easily, and it helps promote uniformity between factories and the products created. However, it can become difficult to introduce new kinds of products since you’d have to make changes in multiple classes to accommodate new methods/properties.
4. Builder
The Builder pattern is one of the most complex yet flexible creational JavaScript design patterns. It allows you to build each feature into your product one by one, providing you full control over how your object is built while still abstracting away the internal details.
In the intricate example below, you’ll see the Builder design pattern in action along with Director to help make Pizzas!
// Here's the PizzaBuilder (you can also call it the chef)
function PizzaBuilder() {
let base
let sauce
let cheese
let toppings = []
// The definition of pizza is hidden from the customers
function Pizza(base, sauce, cheese, toppings) {
this.base = base
this.sauce = sauce
this.cheese = cheese
this.toppings = toppings
this.printInfo = function() {
console.log("This pizza has " + this.base + " base with " + this.sauce + " sauce "
+ (this.cheese !== undefined ? "with cheese. " : "without cheese. ")
+ (this.toppings.length !== 0 ? "It has the following toppings: " + toppings.toString() : ""))
}
}
// You can request the PizzaBuilder (/chef) to perform any of the following actions on your pizza
return {
addFlatbreadBase: function() {
base = "flatbread"
return this;
},
addTomatoSauce: function() {
sauce = "tomato"
return this;
},
addAlfredoSauce: function() {
sauce = "alfredo"
return this;
},
addCheese: function() {
cheese = "parmesan"
return this;
},
addOlives: function() {
toppings.push("olives")
return this
},
addJalapeno: function() {
toppings.push("jalapeno")
return this
},
cook: function() {
if (base === null){
console.log("Can't make a pizza without a base")
return
}
return new Pizza(base, sauce, cheese, toppings)
}
}
}
// This is the Director for the PizzaBuilder, aka the PizzaShop.
// It contains a list of preset steps that can be used to prepare common pizzas (aka recipes!)
function PizzaShop() {
return {
makePizzaMargherita: function() {
pizzaBuilder = new PizzaBuilder()
pizzaMargherita = pizzaBuilder.addFlatbreadBase().addTomatoSauce().addCheese().addOlives().cook()
return pizzaMargherita
},
makePizzaAlfredo: function() {
pizzaBuilder = new PizzaBuilder()
pizzaAlfredo = pizzaBuilder.addFlatbreadBase().addAlfredoSauce().addCheese().addJalapeno().cook()
return pizzaAlfredo
},
makePizzaMarinara: function() {
pizzaBuilder = new PizzaBuilder()
pizzaMarinara = pizzaBuilder.addFlatbreadBase().addTomatoSauce().addOlives().cook()
return pizzaMarinara
}
}
}
// Here's where the customer can request pizzas from
function run() {
let pizzaShop = new PizzaShop()
// You can ask for one of the popular pizza recipes...
let pizzaMargherita = pizzaShop.makePizzaMargherita()
pizzaMargherita.printInfo()
// Output: This pizza has flatbread base with tomato sauce with cheese. It has the following toppings: olives
let pizzaAlfredo = pizzaShop.makePizzaAlfredo()
pizzaAlfredo.printInfo()
// Output: This pizza has flatbread base with alfredo sauce with cheese. It has the following toppings: jalapeno
let pizzaMarinara = pizzaShop.makePizzaMarinara()
pizzaMarinara.printInfo()
// Output: This pizza has flatbread base with tomato sauce without cheese. It has the following toppings: olives
// Or send your custom request directly to the chef!
let chef = PizzaBuilder()
let customPizza = chef.addFlatbreadBase().addTomatoSauce().addCheese().addOlives().addJalapeno().cook()
customPizza.printInfo()
// Output: This pizza has flatbread base with tomato sauce with cheese. It has the following toppings: olives,jalapeno
}
run()
You can pair up the Builder with a Director, as shown by the PizzaShop
class in the example above, to predefine a set of steps to follow every time to build a standard variant of your product, i.e., a specific recipe for your pizzas.
The only issue with this design pattern is that it is quite complex to set up and maintain. Adding new features this way is simpler than the Factory method, though.
5. Prototype
The Prototype design pattern is a quick and simple way of creating new objects from existing objects by cloning them.
A prototype object is first created, which can be cloned multiple times to create new objects. It comes in handy when directly instantiating an object is a more resource-intensive operation compared to creating a copy of an existing one.
In the example below, you’ll see how you can use the Prototype pattern to create new documents based on a set template document:
// Defining how a document would look like
function Document() {
this.header = "Acme Co"
this.footer = "For internal use only"
this.pages = 2
this.text = ""
this.addText = function(text) {
this.text += text
}
// Method to help you see the contents of the object
this.printInfo = function() {
console.log("\n\nHeader: " + this.header + "\nFooter: " + this.footer + "\nPages: " + this.pages + "\nText: " + this.text)
}
}
// A protype (or template) for creating new blank documents with boilerplate information
function DocumentPrototype(baseDocument) {
this.baseDocument = baseDocument
// This is where the magic happens. A new document object is created and is assigned the values of the current object
this.clone = function() {
let document = new Document();
document.header = this.baseDocument.header
document.footer = this.baseDocument.footer
document.pages = this.baseDocument.pages
document.text = this.baseDocument.text
return document
}
}
function run() {
// Create a document to use as the base for the prototype
let baseDocument = new Document()
// Make some changes to the prototype
baseDocument.addText("This text was added before cloning and will be common in both documents. ")
let prototype = new DocumentPrototype(baseDocument)
// Create two documents from the prototype
let doc1 = prototype.clone()
let doc2 = prototype.clone()
// Make some changes to both objects
doc1.pages = 3
doc1.addText("This is document 1")
doc2.addText("This is document 2")
// Print their values
doc1.printInfo()
/* Output:
Header: Acme Co
Footer: For internal use only
Pages: 3
Text: This text was added before cloning and will be common in both documents. This is document 1
*/
doc2.printInfo()
/** Output:
Header: Acme Co
Footer: For internal use only
Pages: 2
Text: This text was added before cloning and will be common in both documents. This is document 2
*/
}
run()
The Prototype method works great for cases where a large part of your objects share the same values or when creating a new object altogether is quite costly. However, it feels like overkill in cases where you don’t need more than a few instances of the class.
Summary
Just like any other programming convention, design patterns are meant to be taken as suggestions for solving problems. They are not laws to be followed all the time, and if you treat them like laws, you might end up doing a lot of damage to your apps.
Once your app is finished, you’ll need a place to host it — and Kinsta’s Application Hosting solutions are chief among the fastest, most reliable, and most secure. Try it now for free right here: https://kinsta.com/application-hosting/
Top comments (0)