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

Published: (December 26, 2025 at 07:44 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

If you come from an Object‑Oriented background (Java / C# /etc.), you are probably familiar with the Singleton Pattern – a class that can have only one instance and provides a global point of access to it.

In traditional JavaScript we often try to mimic this with classes that have static methods or a getInstance() function.
But you don’t need that boilerplate.

JavaScript already gives you a native singleton mechanism: ES6 modules.

Why ES6 Modules Behave as Singletons

When a module is imported, it is executed only once. The exported value is cached by the JavaScript engine, so every subsequent import receives the same instance.

Classical Singleton (Verbose)

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

  private constructor() {} // Prevent 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();

Notice the extra code required just to guarantee a single instance and type safety.

ES6 Module Singleton (Clean & Typed)

// LoggerModule.ts

// 1️⃣ Private state (scoped to this file)
const logs: string[] = [];

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

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

That’s it – no class, no new, no static members, no private keywords. The file scope naturally provides privacy.

What Happens Under the Hood?

StepWhat the engine does
First importExecutes the module, allocates logs.
CachingStores the module instance in an internal cache.
Subsequent importsReturns the cached instance – the same logs array.

Real‑World Example: Global Counter Service

We’ll build a tiny service that lets components increment, read, and subscribe to a global counter. The listener type guarantees strict typing.

// services/CounterService.ts

// 👇 Shape of our listener
type Listener = (count: number) => void;

// 🔒 Private state
let count = 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);
  };
};

Component that modifies the counter

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

const CounterButton: React.FC = () => (
  <div>
    {/* Component A (Modifier) */}
    <div>Initial Value Load: {getValue()}</div>
    <button onClick={increment}>Increment Global Count</button>
  </div>
);

export default CounterButton;

Component that observes the counter

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

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

  useEffect(() => {
    // `subscribe` returns an unsubscribe function – perfect for cleanup
    const unsubscribe = subscribe((newCount) => setCount(newCount));
    return unsubscribe;
  }, []);

  return (
    <div>
      {/* Component B (Observer) */}
      <p>Watching singleton updates…</p>
      <h2>{count}</h2>
    </div>
  );
};

export default CounterDisplay;

Because Listener is typed as (count: number) => void, TypeScript will error if a component tries to subscribe with a mismatched signature (e.g., expecting a string).

When to Use This Pattern

  • API Clients – a single Axios instance with interceptors.
  • WebSocket Connections – one active socket shared across screens.
  • Feature Flags – a simple store to check if a feature is enabled.

Note: This is not a replacement for state‑management libraries (Redux, Zustand, Context API) for complex UI state. It shines for utility logic and single‑purpose services.

Performance Benefits

Cleaner code is great, but the strongest argument is performance.

Modern bundlers (Webpack, Rollup, Vite) perform tree‑shaking – they drop unused code from the final bundle.

The Class Problem

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

// You only use .log(), but .getCount(), .reset(), .debug() …

Because the class is a single export, the bundler must include the entire class (all its methods) even if you only use one, making the bundle larger.

With the module‑function approach, each exported function can be individually eliminated if unused, resulting in a smaller, more efficient bundle.

TL;DR

  • ES6 modules are singletons by design – no extra boilerplate needed.
  • Use module‑scoped variables + exported functions for clean, type‑safe singletons.
  • Ideal for shared utilities, API clients, sockets, and simple stores.
  • Gains: less code, better type safety, and smaller production bundles.
// Logger Example
// Original code
Logger.log('Hello');

The Module Solution

import { log } from './LoggerModule';

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

Benefits of Switching from Class‑based Singletons to ES6 Modules

  • Simplicity – No boilerplate, new keywords, or static methods.
  • Safety – True private state via file‑scope variables.
  • Performance – Granular imports enable 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.

Back to Blog

Related posts

Read more »

FormCN: Generate React Forms in Seconds

!Cover image for FormCN: Generate React Forms in Secondshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F...