Understanding the Singleton Pattern in React

A Singleton is a creational design pattern that restricts a class to a single instance and provides a global point of access to that instance. In the context of React applications, this pattern can serve as a lightweight global state container without introducing external dependencies like Redux or MobX. While React’s built-in Context API and hooks handle many state-sharing needs, the Singleton pattern remains a viable option for certain scenarios—particularly when you need a single, predictable store that is instantiated once and reused across the component tree. This article explores how to implement and leverage the Singleton pattern for global state management in React, weighing its trade-offs against modern alternatives and providing production-ready practices.

Core Concepts of the Singleton Pattern

What Makes a Class a Singleton?

A Singleton implementation must enforce three constraints: a private constructor that prevents external instantiation, a static method that returns the existing instance (or creates one if none exists), and a guarantee that no more than one instance can exist at any time. In JavaScript, this is typically achieved using a module-level variable to cache the instance and a conditional check inside the constructor.

class GlobalState {
  constructor() {
    if (GlobalState.instance) {
      return GlobalState.instance;
    }
    this.state = {};
    GlobalState.instance = this;
  }
}

The pattern works because JavaScript classes can conditionally return an existing instance from the constructor, effectively turning `new GlobalState()` into an idempotent operation. This is the simplest version, but it has limitations—specifically around testability and dependency injection.

The Module Pattern as an Alternative

In ES6 modules, you can achieve Singleton behavior without a class by exporting an object that is created once:

// store.js
let state = {};
export const getState = (key) => state[key];
export const setState = (key, value) => { state[key] = value; };

Because modules are evaluated only once, `state` becomes a de facto Singleton. This approach is often simpler and avoids potential constructor pitfalls, but it lacks the formal API of a class-based solution.

Implementing Singleton for Global State in React

Basic Store with Subscriptions

Global state is useless if components cannot react to changes. A bare Singleton store lacks reactivity—you must extend it with a subscription mechanism. Below is a production-inspired implementation that supports listeners, inspired by patterns used in libraries like Zustand and Redux:

class GlobalStore {
  constructor() {
    if (GlobalStore.instance) return GlobalStore.instance;
    this.state = {};
    this.listeners = new Set();
    GlobalStore.instance = this;
  }

  getState(key) {
    return key ? this.state[key] : this.state;
  }

  setState(updates) {
    const prevState = { ...this.state };
    this.state = { ...this.state, ...updates };
    this.listeners.forEach((listener) => listener(this.state, prevState));
  }

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener); // unsubscribe
  }
}

const store = new GlobalStore();
export default store;

Now React components can subscribe to changes using a custom hook:

import { useState, useEffect } from 'react';
import store from './store';

export function useGlobalState(key) {
  const [value, setValue] = useState(() => store.getState(key));

  useEffect(() => {
    const unsubscribe = store.subscribe((newState) => {
      if (newState[key] !== value) {
        setValue(newState[key]);
      }
    });
    return unsubscribe;
  }, [key]);

  return [value, (newValue) => store.setState({ [key]: newValue })];
}

This hook provides a familiar API similar to `useState` but tied to the global store. The subscription is cleaned up on unmount, preventing memory leaks.

Integrating with Context for Reactivity

Another approach is to combine the Singleton store with React Context. Even though the store instance is global, you can use a Context provider to make the store accessible through the component tree without direct imports, improving testability:

const StoreContext = React.createContext(store);

export function StoreProvider({ children }) {
  // Force re-renders on state change
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
  useEffect(() => {
    const unsub = store.subscribe(() => forceUpdate());
    return unsub;
  }, []);
  return (
    <StoreContext.Provider value={store}>
      {children}
    </StoreContext.Provider>
  );
}

export function useStore() {
  return useContext(StoreContext);
}

With this pattern, components call `useStore().getState('user')` directly, and the provider ensures the tree re-renders when the store updates. This hybrid gives you the simplicity of a Singleton with the ergonomics of React Context.

Advantages of Using the Singleton Pattern

Minimal Boilerplate

For small to medium applications, writing a Singleton store takes fewer lines of code than setting up Redux (actions, reducers, store enhancers) or even creating a multi-provider Context structure. The entire global state can be defined in one file.

Single Source of Truth

The pattern inherently guarantees that all components read from the same object. There is no risk of creating duplicate stores or passing stale props. This is especially valuable for data that must remain consistent across the app, such as authentication tokens, user preferences, or feature flags.

No External Dependencies

You avoid bundle size overhead from state management libraries. This is beneficial for performance-sensitive applications or projects that intentionally limit external dependencies.

Simple Debugging

Because the store is a single object, you can easily attach browser DevTools to inspect its state or log every mutation. A few lines of middleware can give you undo/redo capabilities without a complex framework.

Limitations and When to Avoid the Pattern

Tight Coupling

Directly importing the store singleton into every component creates a hard dependency. This makes unit testing harder—you must import the real store and reset its state between tests, or use module mocking. In contrast, dependency injection through Context or props allows you to provide a mock store during testing.

Scalability Challenges

As the application grows, a single global store becomes a bottleneck. Multiple slices of state managed in one object can lead to unnecessary re-renders if a component subscribes to a broad part of the state. Solutions like selectors or sliced stores (multiple Singletons) can mitigate this but increase complexity.

No Built-in DevTools

Unlike Redux, MobX, or Zustand, there is no dedicated browser extension for Singleton stores. You will need to implement custom logging or time-travel debugging if required.

Potential for Accidental Mutations

If components mutate the store object directly (e.g., `store.state.user.name = 'Bob'`), you lose reactivity and the ability to track changes. A disciplined update method (like `setState` above) is essential but relies on team adherence.

Best Practices for Production Singleton Stores

Use Immutable Updates

Always return new objects from `setState`. This enables the subscription mechanism to detect changes with shallow comparison. Libraries like Immer can be used inside the store for convenience.

import produce from 'immer';

setState(updater) {
  const prevState = this.state;
  this.state = produce(this.state, (draft) => {
    updater(draft);
  });
  this.listeners.forEach((fn) => fn(this.state, prevState));
}

Provide Selector Support

To avoid unnecessary re-renders, modify the `useGlobalState` hook to accept a selector function:

export function useGlobalStateSelector(selector) {
  const [value, setValue] = useState(() => selector(store.getState()));

  useEffect(() => {
    const unsubscribe = store.subscribe((newState, prevState) => {
      const newValue = selector(newState);
      if (newValue !== selector(prevState)) {
        setValue(newValue);
      }
    });
    return unsubscribe;
  }, []);

  return [value, (updater) => store.setState(updater)];
}

Reset State Between Tests

For unit testing, export a method that resets the store to its initial state:

resetState() {
  this.state = {};
  this.listeners = new Set();
}

Call this in `beforeEach` blocks to ensure test isolation.

Consider TypeScript Support

Define a generic type for the store to gain type safety:

type StoreState = {
  user: { id: string; name: string };
  settings: { theme: 'light' | 'dark' };
};

class GlobalStore<T extends Record<string, any>> {
  // ...
}

Comparison with Other React State Management Approaches

Singleton vs React Context

React Context is the official way to share state without prop drilling. Context can be multiple, fine-grained providers, whereas a Singleton forces all state into one namespace. Context also leverages React’s built-in batching and reconciliation, while a custom subscription model may cause additional renders if not optimized. However, Context can lead to “provider hell” in large apps, and every consumer re-renders when the context value changes unless you use memoization. Singleton with selectors can be more granular.

Singleton vs Redux

Redux provides a strict unidirectional data flow, middleware for side effects, DevTools, and a defined pattern for state updates. Singleton stores are far less prescriptive. For complex applications with many developers, Redux’s structure can reduce bugs. For smaller apps or prototypes, Singleton is often overkill with Redux.

Singleton vs Zustand

Zustand is a tiny library that internally uses a Singleton store with subscriptions—very similar to what we built above. Zustand adds built-in support for middleware, slices, and DevTools integration. If you find yourself extending your Singleton with features like persistence or immer integration, it may be worth switching to Zustand to avoid reinventing the wheel.

Performance Considerations

Every subscription in a Singleton store is a function call that runs on every state mutation. With many components subscribed, this can become expensive. To mitigate, use shallow equality checks inside the subscription callback and avoid subscribing entire components—instead, only subscribe to the slices each component needs. The selector-based hook shown earlier is one way to achieve this. Another technique is to use `React.memo` or `useMemo` in the component to skip re-renders when the selected value hasn’t changed.

Memory leaks can occur if components forget to unsubscribe. The cleanup function inside `useEffect` handles this, but ensure all subscriptions are properly cleaned, especially in components that mount/unmount frequently (e.g., list items).

Practical Use Cases for Singleton State

  • Authentication state: A logged-in user object and token that every part of the app needs access to.
  • Feature flags: A simple map of enabled/disabled flags fetched once.
  • Application-wide settings: Theme, language, or layout preferences that rarely change.
  • In-memory cache: Storing results of expensive API calls (e.g., list of countries) to avoid refetching.

Testing a Singleton Store

Unit testing the store itself is straightforward—create an instance, call methods, and assert the state. The challenge is testing components that depend on the global instance. Use the reset pattern above to clear state between test cases. For integration tests, consider wrapping your app with a mock store provider that uses a fresh Singleton instance:

function createTestStore() {
  const testStore = new GlobalStore();
  testStore.setState({ user: { name: 'Test' } });
  return testStore;
}

Then override the context/import in your render functions. Alternatively, use jest.mock to replace the store module with a controlled version.

Conclusion

The Singleton pattern offers a lightweight, intuitive path to global state management in React applications. It shines in scenarios where simplicity and minimal dependencies are paramount—small to medium apps, prototypes, or internal tools. By combining a Singleton store with a custom subscription hook, you can achieve reactive, efficient state sharing without leaving the React ecosystem. However, as applications grow in complexity, you will likely hit the pattern’s limitations around scalability, testability, and tooling. At that point, consider migrating to Zustand, Redux, or React Context with useReducer. For many projects, though, a well-implemented Singleton is all you need—just be careful to enforce immutability, provide selector hooks, and reset state in tests. The pattern remains a valid arrow in any React developer’s quiver, especially when you understand where it works best and where it falls short.