Tools
Tools: React: Singletons aren't as evil as you think
2026-03-04
0 views
admin
The Singleton Scepticism ## The Simplicity of Classes ## Typed Target Event ## Type-Safe CustomEvents: Better Messaging with Native APIs ## Andrew Bone ・ Feb 28 ## A Problem to Solve ## The Singleton ## The Connection ## The Demo ## Closing words In the world of React, the humble singleton gets a bit of a bad rap. It is often dismissed as a messy shortcut to global state, one that is difficult to track and even harder to test. But what if I told you the singleton is not the architectural terror you have been led to believe? What if I showed you it is actually powerful, lightweight and remarkably simple to implement? You might think me mad, but I am about to convince you that singletons are not the villains of the story. Historically, if you wanted to pull data from a singleton in React, you often had to wait for the app to re-render for some other reason. You might have seen a manual sync button or a poll used to bridge the gap, but it was rarely pretty. If this is what you are imagining when I say singletons are easy to implement, I understand the confusion. This is not a good way to implement them and it was not a good way back then either. Let me show you a much nicer approach. This is already much cleaner, more readable and less prone to falling behind. It does, however, require some work in the singleton to make the events happen and we will get to that shortly. Even though this is now perfectly usable, there are a few more tips and tricks we can deploy to make it feel like native React state. Classes are native to JavaScript and as such have no extra package bloat to worry about. You simply define your class, initialise it and off you go. Believe it or not, many parts of the language you probably use every day are classes that can be extended, such as Error, Array, Map or even HTMLElement. Having access to so many pre-made classes means that if we see a behaviour we want to use, we do not have to rewrite it or ship a library for it. We can simply extend the native class and it is there, in the browser, waiting for us. That is the pitch: classes are powerful because we can extend native behaviour, lightweight because they are built into the engine and easy to implement because the documentation is simply the web spec. Earlier I mentioned wanting our singletons to be able to fire events. We can extend EventTarget to do exactly this. However, if you are using TypeScript (and I hope you are) the native implementation can feel a little loose. I have previously written about how to make EventTarget a bit more type-safe to ensure your messaging remains robust. Let us come up with a problem that we do not necessarily need to solve, simply to show off what we can do. The problem I have chosen is a toast manager. We definitely do not need to build one from scratch given that sonner and toastify both exist and are excellent, but this will go a lot smoother with a tangible demo. Building a notification system is the perfect test for our singleton architecture. It needs to be accessible from any part of the application, it must handle its own timers and it should be able to trigger UI updates without being coupled to a specific component tree. As discussed, we are going to extend my TypedEventTarget class, which itself is built on the native EventTarget class. We are going to need a list of toasts, a method to add toasts, a method to remove toasts early and a timer that will remove toasts after enough time has elapsed. We will also have to fire an event every time the list of toasts has changed. Simple enough. First, let us define some types. I am doing this in TypeScript but you do not have to; feel free to skip this bit if you prefer. Now that we have our types, we know what a toast object looks like and what events will be fired. Let us set up the class next. We know it will extend TypedEventTarget and will have some private internals to hide away. This is a good start, but our _toasts property is private, meaning we cannot access it from outside the class, and currently we would have to manually dispatch an event every time we update it. Getters and setters to the rescue. Now we can read our toasts property and even update it internally, but we still cannot control this class externally. We need to add some methods. Finally, we instantiate our class and export it. I do not know about you, but this does not feel like a lot of code. The addition of TypeScript means we get the safety net of auto-completion and type checking without the bloat of a heavy library. When I showed you how to connect to a singleton with a useEffect earlier, I mentioned that it did not quite feel like it was a natural part of React. This is where useSyncExternalStore comes in. It allows us to define a subscription to an external source and a function to retrieve a snapshot of that state, handling the synchronisation for us. First, we need to create the functions to pass to the hook. Now we can put it all together inside a component. This is a somewhat simplistic implementation, but it demonstrates the core principle. We have full access to the data inside the singleton and it triggers a React render cycle whenever the internal state updates. By using useSyncExternalStore, we ensure that our UI is always in sync with our source of truth, without having to manually manage state variables or worry about stale closures. There we have it: a toast manager singleton that feeds into React whenever it needs to, allowing for the controlling and monitoring of toasts from anywhere in the application. I have not gone as far as making a feature-complete product and it certainly will not win any awards for its looks, but please do enjoy the demo. Have I convinced you, or are you still against singletons? Perhaps you were already a fan. I am happy to continue the discussion in the comments. You might be surprised to know that the new, at the time of writing, TanStack Hotkeys actually works in a similar way, with a singleton controller connected to React, or indeed any other library. Thanks for reading! If you'd like to connect, here are my BlueSky and LinkedIn profiles. Come say hi 😊 Templates let you quickly answer FAQs or store snippets for re-use. The real win here is that because this logic is just a standard JS class, it doesn't actually care about React. It makes sharing logic between micro-frontends or different frameworks remarkably trivial. Anyone else using singletons with React or any other frameworks? 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 COMMAND_BLOCK:
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some'; export function ReactElement() { const [singletonData, setSingletonData] = useState(SomeSingleton.data || null); /** * Sync singleton and state */ const handleRefresh = useCallback(() => { setSingletonData(SomeSingleton.data || null) }, []); // Trigger refresh every 5 seconds useEffect(() => { const interval = window.setInterval(handleRefresh, 5000); return () => window.clearInterval(interval); }, [handleRefresh]); return ( <div> <span>{singletonData || 'N/A'}</span> <button onClick={handleRefresh}>Manual Refresh</button> </div> )
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some'; export function ReactElement() { const [singletonData, setSingletonData] = useState(SomeSingleton.data || null); /** * Sync singleton and state */ const handleRefresh = useCallback(() => { setSingletonData(SomeSingleton.data || null) }, []); // Trigger refresh every 5 seconds useEffect(() => { const interval = window.setInterval(handleRefresh, 5000); return () => window.clearInterval(interval); }, [handleRefresh]); return ( <div> <span>{singletonData || 'N/A'}</span> <button onClick={handleRefresh}>Manual Refresh</button> </div> )
} COMMAND_BLOCK:
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some'; export function ReactElement() { const [singletonData, setSingletonData] = useState(SomeSingleton.data || null); /** * Sync singleton and state */ const handleRefresh = useCallback(() => { setSingletonData(SomeSingleton.data || null) }, []); // Trigger refresh every 5 seconds useEffect(() => { const interval = window.setInterval(handleRefresh, 5000); return () => window.clearInterval(interval); }, [handleRefresh]); return ( <div> <span>{singletonData || 'N/A'}</span> <button onClick={handleRefresh}>Manual Refresh</button> </div> )
} COMMAND_BLOCK:
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some'; export function ReactElement() { const [singletonData, setSingletonData] = useState(SomeSingleton.data || null); // Update the state as soon as things change in the singleton useEffect(() => { const ac = new AbortController(); SomeSingleton.addEventListener('change', ({detail})=>{ setSingletonData(detail); }, {signal: ac.signal}) return () => ac.abort(); }, []); return ( <div> <span>{singletonData || 'N/A'}</span> </div> )
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some'; export function ReactElement() { const [singletonData, setSingletonData] = useState(SomeSingleton.data || null); // Update the state as soon as things change in the singleton useEffect(() => { const ac = new AbortController(); SomeSingleton.addEventListener('change', ({detail})=>{ setSingletonData(detail); }, {signal: ac.signal}) return () => ac.abort(); }, []); return ( <div> <span>{singletonData || 'N/A'}</span> </div> )
} COMMAND_BLOCK:
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some'; export function ReactElement() { const [singletonData, setSingletonData] = useState(SomeSingleton.data || null); // Update the state as soon as things change in the singleton useEffect(() => { const ac = new AbortController(); SomeSingleton.addEventListener('change', ({detail})=>{ setSingletonData(detail); }, {signal: ac.signal}) return () => ac.abort(); }, []); return ( <div> <span>{singletonData || 'N/A'}</span> </div> )
} COMMAND_BLOCK:
export interface Toast { id: string; message: string; type: "info" | "success" | "loading" | "error"; action?: { label: string; callback: () => void; };
} type ToastEvents = { changed: void;
}; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
export interface Toast { id: string; message: string; type: "info" | "success" | "loading" | "error"; action?: { label: string; callback: () => void; };
} type ToastEvents = { changed: void;
}; COMMAND_BLOCK:
export interface Toast { id: string; message: string; type: "info" | "success" | "loading" | "error"; action?: { label: string; callback: () => void; };
} type ToastEvents = { changed: void;
}; COMMAND_BLOCK:
class ToastManager extends TypedEventTarget<ToastEvents> { private _toasts: Toast[] = []; private _timers = new Map<string, number>();
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
class ToastManager extends TypedEventTarget<ToastEvents> { private _toasts: Toast[] = []; private _timers = new Map<string, number>();
} COMMAND_BLOCK:
class ToastManager extends TypedEventTarget<ToastEvents> { private _toasts: Toast[] = []; private _timers = new Map<string, number>();
} CODE_BLOCK:
get toasts() { return this._toasts;
} private set toasts(value: Toast[]) { this._toasts = [...value]; this.dispatchEvent("changed");
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
get toasts() { return this._toasts;
} private set toasts(value: Toast[]) { this._toasts = [...value]; this.dispatchEvent("changed");
} CODE_BLOCK:
get toasts() { return this._toasts;
} private set toasts(value: Toast[]) { this._toasts = [...value]; this.dispatchEvent("changed");
} COMMAND_BLOCK:
// add or update a toast item
add = (toast: Omit<Toast, "id"> & { id?: string }, duration = 3000) => { const id = toast.id ?? Math.random().toString(36).substring(2, 9); this.clearTimer(id); const newToast = { ...toast, id }; const exists = this.toasts.some((t) => t.id === id); if (exists) { this.toasts = this.toasts.map((t) => (t.id === id ? newToast : t)); } else { this.toasts = [...this.toasts, newToast]; } if (duration > 0) { const timer = window.setTimeout(() => this.remove(id), duration); this._timers.set(id, timer); } return id;
}; // remove a toast and its timer
remove = (id: string) => { this.clearTimer(id); const index = this.toasts.findIndex(({ id: _id }) => _id === id); if (index >= 0) { this.toasts = this.toasts.filter(({ id: _id }) => _id !== id); }
}; // remove a timer
private clearTimer(id: string) { if (this._timers.has(id)) { clearTimeout(this._timers.get(id)); this._timers.delete(id); }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// add or update a toast item
add = (toast: Omit<Toast, "id"> & { id?: string }, duration = 3000) => { const id = toast.id ?? Math.random().toString(36).substring(2, 9); this.clearTimer(id); const newToast = { ...toast, id }; const exists = this.toasts.some((t) => t.id === id); if (exists) { this.toasts = this.toasts.map((t) => (t.id === id ? newToast : t)); } else { this.toasts = [...this.toasts, newToast]; } if (duration > 0) { const timer = window.setTimeout(() => this.remove(id), duration); this._timers.set(id, timer); } return id;
}; // remove a toast and its timer
remove = (id: string) => { this.clearTimer(id); const index = this.toasts.findIndex(({ id: _id }) => _id === id); if (index >= 0) { this.toasts = this.toasts.filter(({ id: _id }) => _id !== id); }
}; // remove a timer
private clearTimer(id: string) { if (this._timers.has(id)) { clearTimeout(this._timers.get(id)); this._timers.delete(id); }
} COMMAND_BLOCK:
// add or update a toast item
add = (toast: Omit<Toast, "id"> & { id?: string }, duration = 3000) => { const id = toast.id ?? Math.random().toString(36).substring(2, 9); this.clearTimer(id); const newToast = { ...toast, id }; const exists = this.toasts.some((t) => t.id === id); if (exists) { this.toasts = this.toasts.map((t) => (t.id === id ? newToast : t)); } else { this.toasts = [...this.toasts, newToast]; } if (duration > 0) { const timer = window.setTimeout(() => this.remove(id), duration); this._timers.set(id, timer); } return id;
}; // remove a toast and its timer
remove = (id: string) => { this.clearTimer(id); const index = this.toasts.findIndex(({ id: _id }) => _id === id); if (index >= 0) { this.toasts = this.toasts.filter(({ id: _id }) => _id !== id); }
}; // remove a timer
private clearTimer(id: string) { if (this._timers.has(id)) { clearTimeout(this._timers.get(id)); this._timers.delete(id); }
} CODE_BLOCK:
export const toastManager = new ToastManager(); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
export const toastManager = new ToastManager(); CODE_BLOCK:
export const toastManager = new ToastManager(); COMMAND_BLOCK:
import { toastManager } from '@/singletons/toastManager'; // Add an event listener
const subscribe = (callback: () => void) => { const ac = new AbortController(); toastManager.addEventListener("changed", callback, { signal: ac.signal, }); return () => ac.abort();
}; // Get the state
const getSnapshot = () => toastManager.toasts; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { toastManager } from '@/singletons/toastManager'; // Add an event listener
const subscribe = (callback: () => void) => { const ac = new AbortController(); toastManager.addEventListener("changed", callback, { signal: ac.signal, }); return () => ac.abort();
}; // Get the state
const getSnapshot = () => toastManager.toasts; COMMAND_BLOCK:
import { toastManager } from '@/singletons/toastManager'; // Add an event listener
const subscribe = (callback: () => void) => { const ac = new AbortController(); toastManager.addEventListener("changed", callback, { signal: ac.signal, }); return () => ac.abort();
}; // Get the state
const getSnapshot = () => toastManager.toasts; COMMAND_BLOCK:
import { useSyncExternalStore } from 'react'; export default function ToastContainer() { const toastList = useSyncExternalStore(subscribe, getSnapshot); return ( <ul> {toastList.map(({id, message}) => (<li key={id}>{message}</li>))} </ul> );
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { useSyncExternalStore } from 'react'; export default function ToastContainer() { const toastList = useSyncExternalStore(subscribe, getSnapshot); return ( <ul> {toastList.map(({id, message}) => (<li key={id}>{message}</li>))} </ul> );
} COMMAND_BLOCK:
import { useSyncExternalStore } from 'react'; export default function ToastContainer() { const toastList = useSyncExternalStore(subscribe, getSnapshot); return ( <ul> {toastList.map(({id, message}) => (<li key={id}>{message}</li>))} </ul> );
} - Location Britain, Europe
- Pronouns He/Him
- Work Senior Web Developer at bloc-digital
- Joined Jun 8, 2017
how-totutorialguidedev.toaimljavascriptgit