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();
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;
};
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:
-
First Import: When your app first starts and
import ... from './LoggerModule'is called, the JS engine executes the file and allocates memory forlogs. - Caching: The engine caches this specific module instance.
-
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);
};
};
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;
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;
When to Use This
This pattern is perfect for:
- API Clients: Configuring a single Axios instance with interceptors.
- WebSocket Connections: Maintaining one active socket connection shared across screens.
- 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');
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');
Final Summary
By switching from Class-based Singletons to ES6 Modules, you gain:
-
Simplicity: No boilerplate,
newkeywords, orstaticmethods. - Safety: True private state via file-scope variables.
- 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)