Search icon CANCEL
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Conferences
Free Learning
Arrow right icon
Arrow up icon
GO TO TOP
JavaScript Design Patterns

You're reading from   JavaScript Design Patterns Deliver fast and efficient production-grade JavaScript applications at scale

Arrow left icon
Product type Paperback
Published in Mar 2024
Publisher Packt
ISBN-13 9781804612279
Length 308 pages
Edition 1st Edition
Languages
Tools
Arrow right icon
Author (1):
Arrow left icon
Hugo Di Francesco Hugo Di Francesco
Author Profile Icon Hugo Di Francesco
Hugo Di Francesco
Arrow right icon
View More author details
Toc

Table of Contents (16) Chapters Close

Preface 1. Part 1:Design Patterns
2. Chapter 1: Working with Creational Design Patterns FREE CHAPTER 3. Chapter 2: Implementing Structural Design Patterns 4. Chapter 3: Leveraging Behavioral Design Patterns 5. Part 2:Architecture and UI Patterns
6. Chapter 4: Exploring Reactive View Library Patterns 7. Chapter 5: Rendering Strategies and Page Hydration 8. Chapter 6: Micro Frontends, Zones, and Islands Architectures 9. Part 3:Performance and Security Patterns
10. Chapter 7: Asynchronous Programming Performance Patterns 11. Chapter 8: Event-Driven Programming Patterns 12. Chapter 9: Maximizing Performance – Lazy Loading and Code Splitting 13. Chapter 10: Asset Loading Strategies and Executing Code off the Main Thread 14. Index 15. Other Books You May Enjoy

The singleton pattern with eager and lazy initialization in JavaScript

To begin, let’s define the singleton design pattern.

The singleton pattern allows an object to be instantiated only once, exposes this single instance to consumers, and controls the instantiation of the single instance.

The singleton is another way of getting access to an object instance without using a constructor, although it’s necessary for the object to be designed as a singleton.

Implementation

A classic example of a singleton is a logger. It’s rarely necessary (and often, it’s a problem) to instantiate multiple loggers in an application. Having a singleton means the initialization site is controlled, and the logger configuration will be consistent across the application – for example, the log level won’t change depending on where in the application we call the logger from.

A simple logger looks something as follows, with a constructor taking logLevel and transport, and an isLevelEnabled private method, which allows us to drop logs that the logger is not configured to keep (for example, when the level is warn we drop info messages). The logger finally implements the info, warn, and error methods, which behave as previously described; they only call the relevant transport method if the level is “enabled” (i.e., “above” what the configured log level is).

The possible logLevel values that power isLevelEnabled are stored as a static field on Logger:

class Logger {
  static logLevels = ['info', 'warn', 'error'];
  constructor(logLevel = 'info', transport = console) {
    if (Logger.#loggerInstance) {
      throw new TypeError(
        'Logger is not constructable, use getInstance()
           instead'
      );
    }
    this.logLevel = logLevel;
    this.transport = transport;
  }
  isLevelEnabled(targetLevel) {
    return (
      Logger.logLevels.indexOf(targetLevel) >=
      Logger.logLevels.indexOf(this.logLevel)
    );
  }
  info(message) {
    if (this.isLevelEnabled('info')) {
      return this.transport.info(message);
    }
  }
  warn(message) {
    if (this.isLevelEnabled('warn')) {
      this.transport.warn(message);
    }
  }
  error(message) {
    if (this.isLevelEnabled('error')) {
      this.transport.error(message);
    }
  }
}

In order to make Logger a singleton, we need to implement a getInstance static method that returns a cached instance. In order to do, this we’ll use a static loggerInstance on Logger. getInstance will check whether Logger.loggerInstance exists and return it if it does; otherwise, it will create a new Logger instance, set that as loggerInstance, and return it:

class Logger {
  static loggerInstance = null;
  // rest of the class
  static getInstance() {
    if (!Logger.loggerInstance) {
      Logger.loggerInstance = new Logger('warn', console);
    }
    return Logger.loggerInstance;
  }
}

Using this in another module is as simple as calling Logger.getInstance(). All getInstance calls will return the same instance of Logger:

const a = Logger.getInstance();
const b = Logger.getInstance();
console.assert(a === b, 'Logger.getInstance() returns the
  same reference');

We’ve implemented a singleton with “lazy” initialization. The initialization occurs when the first getInstance call is made. In the next section, we’ll see how we might extend our code to have an “eager” initialization of loggerInstance, where loggerInstance will be initialized when the Logger code is evaluated.

Ensuring only one singleton instance is constructed

A characteristic of a singleton is the “single instance” concept. We want to “force” consumers to use the getInstance method.

In order to do this, we can check for the existence of loggerInstance when the contructor is called:

class Logger {
  // rest of the class
  constructor(logLevel = 'info', transport = console) {
    if (Logger.loggerInstance) {
      throw new TypeError(
        'Logger is not constructable, use getInstance()
          instead'
      );
    }
    this.logLevel = logLevel;
    this.transport = transport;
  }
  // rest of the class
}

In the case where we call getInstance (and, therefore, Logger.loggerInstance is populated), the constructor will now throw an error:

Logger.getInstance();
new Logger('info', console); // new TypeError('Logger is
  not constructable, use getInstance() instead');

This behavior is useful to ensure that consumers don’t instantiate their own Logger and they use getInstance instead. All consumers using getInstance means the configuration to set up the logger is encapsulated by the Logger class.

There’s still a gap in the implementation, as constructing new Logger() before any getInstance() calls will succeed, as shown in the following example:

new Logger('info', console); // Logger { logLevel: 'info',
  transport: ... }
new Logger('info', console); // Logger { logLevel: 'info',
  transport: ... }
Logger.getInstance();
new Logger('info', console); // new TypeError('Logger is
  not constructable, use getInstance() instead');

In multithreaded languages, our implementation would also have a potential race condition – multiple consumers calling Logger.getInstance() concurrently could cause multiple instances to exist. However, since popular JavaScript runtimes are single-threaded, we won’t have to worry about such a race condition – getInstance is a “synchronous” method, so multiple calls to it would be interpreted one after the other. For reference, Node.js, Deno, and the mainstream browsers Chrome, Safari, Edge, and Firefox provide a single-threaded JavaScript runtime.

Singleton with eager initialization

Eager initialization can be useful to ensure that the singleton is ready for use and features, such as disabling the constructor when an instance exists, work for all cases.

We can eager-initialize by setting Logger.loggerInstance in the Logger constructor:

class Logger {
  // rest of the class unchanged
  constructor(logLevel = 'info', transport = console) {
    // rest of the constructor unchanged
    Logger.loggerInstance = this;
  }
}

This approach has the downside of the constructor performing a global state mutation, which isn’t ideal from a “single responsibility principle” standpoint; the constructor now has a side-effect of sorts (mutating global state) beyond its responsibility to set up an object instance.

An alternative way to eager-initialize is by running Logger.getInstance() in the logger’s module; it’s useful to pair it with an export default statement:

export class Logger {
  // no changes to the Logger class
}
export default Logger.getInstance();

With the preceding exports added, there are now two ways to access a logger instance. The first is to import Logger by name and call Logger.getInstance():

import { Logger } from './logger.js';
const logger = Logger.getInstance();
logger.warn('testing testing 12'); // testing testing 12

The second way to use the logger is by importing the default export:

import logger from './logger.js';
logger.warn('testing testing 12'); // testing testing 12

Any code now importing Logger will get a pre-determined singleton instance of the logger.

Use cases

A singleton shines when there should only be one instance of an object in an application – for example, a logger that shouldn’t be set up/torn down on every request.

Since the singleton class controls how it gets instantiated, it’s also a good fit for objects that are tricky to configure (again, a logger, a metrics exporter, and an API client are good examples). The instantiation is completely encapsulated if, like in our example, we “disable” the constructor.

There’s a performance benefit to constraining the application to a single instance of an object in terms of memory footprint.

The major drawbacks of singletons are an effect of their reliance on global state (in our example, the static loggerInstance). It’s hard to test a singleton, especially in a case where the constructor is “disabled” (like in our example), since our tests will want to always have a single instance of the singleton.

Singletons can also be considered “global state” to some extent, which comes with all its drawbacks. Global state can sometimes be a sign of poor design, and updating/consuming global state is error-prone (e.g., if a consumer is reading state but it is then updated and not read again).

Improvements with the “class singleton” pattern

With our singleton logger implementation, it’s possible to modify the internal state of the singleton from outside of it. This is nothing specific to our singleton; it’s the nature of JavaScript. By default, its fields and methods are public.

However, this is a bigger issue in our singleton scenario, since a consumer could reset loggerInstance using a statement such as Logger.loggerInstance = null or delete Logger.loggerInstance. See the following example:

const logger = Logger.getInstance();
Logger.loggerInstance = null;
const logger = new Logger('info', console); // should throw but creates a new instance

In order to stop consumers from modifying the loggerInstance static field, we can make it a private field. Private fields in JavaScript are part of the ECMAScript 2023 specification (the 13th ECMAScript edition).

To define a private field, we use the # prefix for the field name – in this case, loggerInstance becomes #loggerInstance. The isLevelEnabled method becomes #isLevelEnabled, and we also declare logLevel and transport as #logLevel and #transport, respectively:

export class Logger {
  // other static fields are unchanged
  static #loggerInstance = null;
  #logLevel;
  #transport;
  constructor(logLevel = 'info', transport = console) {
    if (Logger.#loggerInstance) {
      throw new TypeError(
        'Logger is not constructable, use getInstance()
          instead'
      );
    }
    this.#logLevel = logLevel;
    this.#transport = transport;
  }
  #isLevelEnabled(targetLevel) {
    // implementation unchanged
  }
  info(message) {
    if (this.#isLevelEnabled('info')) {
      return this.#transport.info(message);
    }
  }
  warn(message) {
    if (this.#isLevelEnabled('warn')) {
      this.#transport.warn(message);
    }
  }
  error(message) {
    if (this.#isLevelEnabled('error')) {
      this.#transport.error(message);
    }
  }
  getInstance() {
    if (!Logger.#loggerInstance) {
      Logger.#loggerInstance = new Logger('warn', console);
    }
    return Logger.#loggerInstance;
  }
}

It’s not possible to delete loggerInstace or set it to null, since attempting to access Logger.#loggerInstance is a syntax error:

  Logger.#loggerInstance = null;
        ^
SyntaxError: Private field '#loggerInstance' must be
  declared in an enclosing class

Another useful technique is to disallow modification of fields on an object. In order to disallow modification, we can use Object.freeze to freeze the instance once it’s created.

class Logger {
  // no changes to the logger class
}
export default Object.freeze(new Logger('warn', console));

Now, when someone attempts to change a field on the Logger instance, they’ll get TypeError:

import logger from './logger.js';
logger.transport = {}; // new TypeError('Cannot add
  property transport, object is not extensible')

We’ve now refactored our singleton implementation to disallow external modifications to it by using private fields and Object.freeze. Next, we’ll see how to use EcmaScript (ES) modules to deliver singleton functionality.

A singleton without class fields using ES module behavior

The JavaScript module system has the following caching behavior – if a module is loaded, any further imports of the module’s exports will be cached instances of exports.

Therefore, it’s possible to create a singleton as follows in JavaScript.

class MySingleton {
  constructor(value) {
    this.value = value;
  }
}
export default new MySingleton('my-value');

Multiple imports of the default export will result in only one existing instance of the MySingleton object. Furthermore, if we don’t export the class, then the constructor doesn’t need to be “protected.”

As the following snippet with dynamic imports shows, both import('./my-singleton.js') result in the same object. They both return the same object because the output of the import for a given module is a singleton:

await Promise.all([
  import('./my-singleton.js'),
  import('./my-singleton.js'),
]).then(([import1, import2]) => {
  console.assert(
    import1.default.value === 'my-value' &&
      import2.default.value === 'my-value',
    'instance variable is equal'
  );
  console.assert(
    import1.default === import2.default,
    'multiple imports of a module yield the same default
      object value, a single MySingleton instance'
  );
  console.assert(import1 === import2, 'import objects are a
    single reference');
});

For our logger, this means we could implement an eager-initialized singleton in JavaScript without any of the heavy-handed guarding of the constructor or even a getInstance method. Note the use of logLevel and isLevelEnabled as a public instance property and a public method, respectively (since it might be useful to have access to them from a consumer). In the meantime, #transport remains private, and we’ve dropped loggerInstance and getInstance. We’ve kept Object.freeze(), which means that even though logLevel is readable from a consumer, it’s not available to modify:

class Logger {
  static logLevels = ['info', 'warn', 'error'];
  #transport;
  constructor(logLevel = 'info', transport = console) {
    this.logLevel = logLevel;
    this.#transport = transport;
  }
  isLevelEnabled(targetLevel) {
    return (
      Logger.logLevels.indexOf(targetLevel) >=
      Logger.logLevels.indexOf(this.logLevel)
    );
  }
  info(message) {
    if (this.isLevelEnabled('info')) {
      return this.#transport.info(message);
    }
  }
  warn(message) {
    if (this.isLevelEnabled('warn')) {
      this.#transport.warn(message);
    }
  }
  error(message) {
    if (this.isLevelEnabled('error')) {
      this.#transport.error(message);
    }
  }
}
export default Object.freeze(new Logger('warn', console));

In this part of the chapter, we learned how to implement the singleton pattern with a class that exposes a getInstance() method, as well as the difference between the eager and lazy initialization of a singleton. We’ve covered some JavaScript features, such as private class fields and Object.freeze, which can be useful when implementing the singleton pattern. Finally, we explored how JavaScript/ECMAScript modules have singleton-like behavior and can be relied upon to provide this behavior for a class instance.

In the next section, we’ll explore the final creational design pattern covered in this chapter – the factory design pattern.

You have been reading a chapter from
JavaScript Design Patterns
Published in: Mar 2024
Publisher: Packt
ISBN-13: 9781804612279
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime