DEV Community

Cover image for Stop Writing Singleton Classes: Use ES6 Modules (The TypeScript Way)
Saiful Bashar
Saiful Bashar

Posted on

Stop Writing Singleton Classes: Use ES6 Modules (The TypeScript Way)

If you come from an Object-Oriented background (Java, C#, etc.), you are likely familiar with the Singleton Pattern. It ensures a class has only one instance and provides a global point of access to it.

In traditional JavaScript, we often try to mimic this by writing Classes with static methods or complex getInstance() logic. But here is the secret: You don't need that boilerplate.

JavaScript has a built-in, native Singleton mechanism that you are probably already using: The ES6 Module.

In this post, I’ll explain why ES6 modules behave as singletons and show you a practical, type-safe React example of how to use them to share state or logic.


The "Old" Way (The Class Approach)

If you strictly follow the classical definition, a Singleton in JavaScript usually looks something like this. Note how verbose it becomes just to ensure type safety and singular existence.

// LoggerClass.ts
class Logger {
  private static instance: Logger;
  private logs: string[] = [];

  private constructor() {} // Private constructor prevents direct instantiation

  public static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  public log(message: string): void {
    this.logs.push(message);
    console.log(`LOG: ${message}`);
  }

  public getCount(): number {
    return this.logs.length;
  }
}

export default Logger.getInstance();

Enter fullscreen mode Exit fullscreen mode

Using ES6 Modules

ES6 Modules are singletons by default. When you import a module, it is executed only once. The exported value is cached by the JavaScript engine.

Here is the exact same functionality, but cleaner and fully typed:

// LoggerModule.ts

// 1. Private State (Scoped to this file only)
const logs: string[] = [];

// 2. Exported Functions (The public API)
export const log = (message: string): void => {
  logs.push(message);
  console.log(`LOG: ${message}`);
};

export const getCount = (): number => {
  return logs.length;
};

Enter fullscreen mode Exit fullscreen mode

That’s it. No class, no new, no static, and no private keywords required—the file scope handles privacy naturally.


How It Works: The "Magic" of Module Caching

You might be wondering: "If I import this file in two different components, won't it reset the logs array?"

No.

Here is what happens under the hood:

  1. First Import: When your app first starts and import ... from './LoggerModule' is called, the JS engine executes the file and allocates memory for logs.
  2. Caching: The engine caches this specific module instance.
  3. Subsequent Imports: When another file imports ./LoggerModule, the engine looks at its cache. It sees the module is already loaded and hands back the reference to the exact same memory space.

Practical React + TypeScript Example

Let's build a Global Counter Service. We will define types for our subscriptions so consumers can't mess up the data flow.

1. The Singleton Service (Type-Safe)

We define a specific type for our listener callback to ensure strict typing across the app.

// services/CounterService.ts

// Define the shape of our listener
type Listener = (count: number) => void;

// Private state
let count: number = 0;
let listeners: Listener[] = [];

// Helper to notify all listeners
const notify = (): void => {
  listeners.forEach((listener) => listener(count));
};

// Public API
export const increment = (): void => {
  count += 1;
  notify();
};

export const getValue = (): number => count;

export const subscribe = (listener: Listener): (() => void) => {
  listeners.push(listener);

  // Return an unsubscribe function for cleanup
  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
};

Enter fullscreen mode Exit fullscreen mode

2. Component A (The Modifier)

This component imports the functions directly. TypeScript knows exactly what increment does.

// components/CounterButton.tsx
import React from 'react';
import { increment, getValue } from '../services/CounterService';

const CounterButton: React.FC = () => {
  return (
    <div style={{ border: '1px solid blue', padding: '16px', margin: '16px' }}>
      <h3>Component A (Modifier)</h3>
      <p>Initial Value Load: {getValue()}</p>
      <button onClick={increment}>Increment Global Count</button>
    </div>
  );
};

export default CounterButton;

Enter fullscreen mode Exit fullscreen mode

3. Component B (The Observer)

This component subscribes to changes. Thanks to the Listener type we defined earlier, TypeScript will error if we try to pass a listener that expects a string instead of a number.

// components/CounterDisplay.tsx
import React, { useState, useEffect } from 'react';
import { getValue, subscribe } from '../services/CounterService';

const CounterDisplay: React.FC = () => {
  // Initialize state with the current value of the singleton
  const [count, setCount] = useState<number>(getValue());

  useEffect(() => {
    // Subscribe returns the cleanup function, which useEffect uses perfectly
    const unsubscribe = subscribe((newCount) => {
      setCount(newCount);
    });

    return unsubscribe;
  }, []);

  return (
    <div style={{ border: '1px solid green', padding: '16px', margin: '16px' }}>
      <h3>Component B (Observer)</h3>
      <p>Watching singleton updates...</p>
      <h1>{count}</h1>
    </div>
  );
};

export default CounterDisplay;

Enter fullscreen mode Exit fullscreen mode

When to Use This

This pattern is perfect for:

  1. API Clients: Configuring a single Axios instance with interceptors.
  2. WebSocket Connections: Maintaining one active socket connection shared across screens.
  3. Feature Flags: A simple store to check if a feature is enabled.

It is not a replacement for Redux, Zustand, or Context API for complex UI state, but for utility logic and single-purpose services, it is the cleanest, most efficient solution.

Conclusion: The Hidden Performance Win (Tree Shaking)

While code cleanliness is a great reason to switch to ES6 Modules, the strongest argument might actually be performance.

Modern bundlers like Webpack, Rollup, and Vite use a process called Tree Shaking to remove unused code from your final production bundle.

Class Singleton vs. Module Singleton

The Class Problem:
When you export a class instance, it is treated as a single object. Even if you only use one method from that class, the bundler often has to include the entire class and all its methods because it's difficult to statically analyze which methods on the prototype chain are truly unused.

// Traditional Class Import
import Logger from './LoggerClass';

// You only use .log(), but .getCount(), .reset(), and .debug() 
// are likely still included in your bundle.
Logger.log('Hello'); 

Enter fullscreen mode Exit fullscreen mode

The Module Solution:
With ES6 Modules, exports are static. If you have a utility file with 50 functions but only import one, the bundler can completely strip out the other 49 functions from the final build.

// ES6 Module Import
import { log } from './LoggerModule';

// The bundler sees that `getCount` is never imported.
// It removes it from the final JavaScript bundle.
log('Hello'); 

Enter fullscreen mode Exit fullscreen mode

Final Summary

By switching from Class-based Singletons to ES6 Modules, you gain:

  1. Simplicity: No boilerplate, new keywords, or static methods.
  2. Safety: True private state via file-scope variables.
  3. Performance: Granular imports allow for better Tree Shaking, resulting in smaller bundle sizes.

So, the next time you reach for a Singleton in React or TypeScript, remember: You probably just need a module.

Top comments (0)