Tools
Stop Writing Singleton Classes: Use ES6 Modules (The TypeScript Way)
2025-12-26
0 views
admin
The "Old" Way (The Class Approach) ## Using ES6 Modules ## How It Works: The "Magic" of Module Caching ## Practical React + TypeScript Example ## 1. The Singleton Service (Type-Safe) ## 2. Component A (The Modifier) ## 3. Component B (The Observer) ## When to Use This ## Conclusion: The Hidden Performance Win (Tree Shaking) ## Class Singleton vs. Module Singleton ## Final Summary 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. 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. 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: That’s it. No class, no new, no static, and no private keywords required—the file scope handles privacy naturally. You might be wondering: "If I import this file in two different components, won't it reset the logs array?" Here is what happens under the hood: Let's build a Global Counter Service. We will define types for our subscriptions so consumers can't mess up the data flow. We define a specific type for our listener callback to ensure strict typing across the app. This component imports the functions directly. TypeScript knows exactly what increment does. 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. This pattern is perfect for: 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. 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. 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. 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. By switching from Class-based Singletons to ES6 Modules, you gain: So, the next time you reach for a Singleton in React or TypeScript, remember: You probably just need a module. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
// 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 CODE_BLOCK:
// 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(); CODE_BLOCK:
// 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(); COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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;
}; COMMAND_BLOCK:
// 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;
}; COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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); };
}; COMMAND_BLOCK:
// 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); };
}; COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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; COMMAND_BLOCK:
// 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; COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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; COMMAND_BLOCK:
// 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; CODE_BLOCK:
// 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 CODE_BLOCK:
// 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'); CODE_BLOCK:
// 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'); CODE_BLOCK:
// 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 CODE_BLOCK:
// 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'); CODE_BLOCK:
// 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'); - First Import: When your app first starts and import ... from './LoggerModule' is called, the JS engine executes the file and allocates memory for logs.
- 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. - 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. - Simplicity: No boilerplate, new keywords, or static methods.
- Safety: True private state via file-scope variables.
- Performance: Granular imports allow for better Tree Shaking, resulting in smaller bundle sizes.
how-totutorialguidedev.toaiserverswitchjavascript