Design Patterns in React: Harnessing JavaScript Best Practices for Scalable Applications - Singleton design pattern
The Singleton design pattern is a creational design pattern. Its main goal is to make sure that a class has only one instance and to provide a global access point.
Because of JavaScript’s dynamic nature and modules, the way to implement a singleton is different from traditional object-oriented languages. But the core idea is the same:
- Uniqueness: No matter how many times you call the constructor, you always get the same instance.
- Global access: You get the instance through a single entry point, avoiding scattered instantiation logic.
Usually, when we create a class (which is basically a constructor function), we can use the new keyword to call the constructor and create as many instance objects as we want, like this:
class Single {
log() {
console.log('I am an object');
}
}
const s1 = new Single();
const s2 = new Single();
// false
s1 === s2;
Next, we will implement the Singleton pattern.
Implementation
ES6 Version
In ES6, we can use a static property to store the instance and check if the instance exists in the constructor to implement a singleton pattern. Also, because calling new directly might be bypassed, we combine it with module export to enhance security.
class Single {
static instance;
constructor() {
if (Single.instance) {
return Single.instance;
}
this.data = "Class Single";
Single.instance = this;
}
log() {
console.log('I am a singleton object');
}
}
const singleton = new Single();
export default singleton;
ES5 + Closures
In ES5, we can implement the Singleton pattern using closures and immediately invoked functions.
- Closure: Used to hide and keep the singleton instance (a private variable).
- Immediately invoked function: Creates an independent scope to make sure the singleton logic is isolated.
- Factory function: Returns a method to get the singleton instance.
var Single = (function () {
// The singleton instance is saved inside the closure (a private variable)
var instance;
// The real constructor function (can accept arguments)
function SingleConstructor(name) {
this.name = name || 'DefaultInstance';
this.sayHello = function () {
console.log('Hello from ' + this.name);
};
}
// Return an object that exposes a method to get the singleton
return {
getInstance: function (name) {
if (!instance) {
// Create the instance only the first time it's called
instance = new SingleConstructor(name);
}
return instance;
}
};
})();
// Usage example
var instance1 = Single.getInstance('Instance1');
var instance2 = Single.getInstance('Instance2');
instance1.sayHello(); // Outputs: Hello from Instance1
instance2.sayHello(); // Outputs: Hello from Instance1 (name hasn't changed)
console.log(instance1 === instance2); // true
Applications
The Singleton pattern is one of the easier design patterns to understand and use. It has a wide range of applications and performs well in the following scenarios:
- Global state management: Such as user login information, app configuration, etc.
- Shared resource access: Database connection pools, logging services, and other scenarios where repeated initialization should be avoided.
- Caching systems: Manage cached data in a unified way to prevent data inconsistency caused by multiple instantiations.
React Context
// GlobalContext.jsx
import React, { createContext, useContext, useState } from 'react';
const GlobalContext = createContext();
export const GlobalProvider = ({ children }) => {
const [config, setConfig] = useState("default");
return (
<GlobalContext.Provider value={{ config, setConfig }}>
{children}
</GlobalContext.Provider>
);
};
export const useGlobal = () => useContext(GlobalContext);
// App.jsx
import { GlobalProvider } from './GlobalContext';
const App = () () (
<GlobalProvider>
<ComponentA />
<ComponentB />
</GlobalProvider>
);
React Global Modal
The above React Context implementation is a classic Singleton pattern. Now, let’s implement a more complex modal module.
// modal-manager.ts
type ModalState = {
isOpen: boolean;
content: React.ReactNode;
};
type Listener = (state: ModalState) => void;
class ModalManager {
private static instance: ModalManager;
private state: ModalState = { isOpen: false, content: null };
private listeners: Listener[] = [];
private constructor() {}
public static getInstance(): ModalManager {
if (!ModalManager.instance) {
ModalManager.instance = new ModalManager();
}
return ModalManager.instance;
}
public show(content: React.ReactNode) {
this.state = { isOpen: true, content };
this.notifyListeners();
}
public hide() {
this.state = { ...this.state, isOpen: false };
this.notifyListeners();
}
public subscribe(listener: Listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
public getCurrentState() {
return this.state;
}
private notifyListeners() {
this.listeners.forEach(listener => listener(this.state));
}
}
export const modalManager = ModalManager.getInstance();
// ModalContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { modalManager } from './modal-manager';
const ModalContext = createContext<{
isOpen: boolean;
content: React.ReactNode;
}>(modalManager.getCurrentState());
export const ModalProvider = ({ children }: { children: React.ReactNode }) => {
const [modalState, setModalState] = useState(modalManager.getCurrentState());
useEffect(() => {
return modalManager.subscribe(newState => {
setModalState(newState);
});
}, []);
return (
<ModalContext.Provider value={modalState}>
{children}
</ModalContext.Provider>
);
};
export const useModal = () => {
return {
show: (content: React.ReactNode) => modalManager.show(content),
hide: () => modalManager.hide()
};
};
export const useModalState = () => {
return useContext(ModalContext);
};
// GlobalModal.tsx
import { useModalState } from './ModalContext';
export const GlobalModal = () => {
const { isOpen, content } = useModalState();
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg max-w-md w-full">
{content}
</div>
</div>
);
};
In the code above, we use:
- The ModalManager class with a static method to ensure a globally unique instance maintains the modal state and a list of subscribers, providing show/hide methods to change the state.
- React Context to pass the state.
- A subscription mechanism to synchronize the singleton state with React component states.
- The ModalProvider component to connect the Singleton pattern with React.
- This creates a global singleton modal.
// App.tsx
import { ModalProvider } from './ModalContext';
import { GlobalModal } from './GlobalModal';
function App() {
return (
<ModalProvider>
<div className="app">
<GlobalModal />
{/* Other app content */}
</div>
</ModalProvider>
);
}
// child component
import { useModal } from './ModalContext';
function SomeComponent() {
const { show, hide } = useModal();
return (
<button onClick={() => show(<div>Hello Modal!</div>)}>
open dialog
</button>
);
}
You can see the specific code at https://codesandbox.io/p/devbox/zv3723
Summary
In daily development, the Singleton pattern is very effective for resource optimization, global access, and consistency. However, we also need to be careful about the side effects it can bring, such as:
- Difficult testing: Global state may pollute the testing environment and needs to be manually reset.
- High coupling: Components depending on the singleton can reduce code maintainability.
- Memory leaks: Long-lived instances may accumulate useless data.
- Violation of single responsibility: The singleton class needs to manage both its own instance and business logic.