How the Singleton Principle Works in JavaScript: A Comprehensive Case Analysis

Akash JayanAkash Jayan
5 min read

In software design, the Singleton pattern is a widely used design principle that restricts the instantiation of a class to a single instance. This is particularly useful when exactly one object is needed to coordinate actions across the system. In this blog post, we will explore the Singleton principle through a practical example from a Next.js project that implements an authentication service.

What is the Singleton Pattern?

The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance. This is achieved by:

  1. Private Constructor: The constructor of the class is made private to prevent direct instantiation.

  2. Static Instance: A static variable holds the single instance of the class.

  3. Static Method: A static method is provided to access the instance, creating it if it does not already exist.

Why Use the Singleton Pattern?

The Singleton pattern is beneficial in scenarios where:

  • You need to control access to shared resources, such as a configuration object or a connection pool.

  • You want to ensure that a class has only one instance throughout the application lifecycle.

  • You need a centralized point of control for certain functionalities, like logging or managing application state.

Implementing the Singleton Pattern

In our Next.js project, we have an AuthService class that manages authentication-related functionalities. Let's take a closer look at how the Singleton pattern is implemented in this class.

The AuthService Class

Here’s the implementation of the AuthService class:


class AuthService {
  private static instance: AuthService;
  private accessToken: string | null = null;

  private constructor() {}

  public static getInstance(): AuthService {
    if (!AuthService.instance) {
      AuthService.instance = new AuthService();
    }
    return AuthService.instance;
  }

  public setToken(token: string) {
    this.accessToken = token;
  }

  public getToken(): string | null {
    return this.accessToken;
  }

  public async loginWithEmail(email: string, password: string): Promise<void> {
    debugger
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await response.json();
      this.setToken(data.accessToken);
    } catch (error) {
      console.error('Error logging in with email:', error);
    }
  }

  public async refreshAccessToken(): Promise<void> {
    try {
      const response = await fetch('/api/auth/refreshToken');
      const data = await response.json();
      this.setToken(data.accessToken);
    } catch (error) {
      console.error('Error refreshing access token:', error);
    }
  }

  public logout(): void {
    this.accessToken = null;
  }
}

export default AuthService;

Breakdown of the Implementation

  1. Private Constructor: The constructor is private, preventing external instantiation of the AuthService class.

  2. Static Instance: A static variable instance holds the single instance of the class.

  3. Static Method getInstance: This method checks if an instance already exists. If not, it creates one and returns it. This ensures that no matter how many times getInstance is called, only one instance of AuthService will be created.

  4. Token Management: The class provides methods to set and get the access token, as well as to log in and refresh the token. This encapsulates the authentication logic within a single instance, ensuring that the application state is managed consistently.

Using the AuthProvider Component

In addition to the AuthService, we have an AuthProvider component that utilizes React's context API to manage authentication state across the application. Here’s how it is implemented:

"use client";
import { createContext, useState, useEffect, ReactNode, useContext } from 'react';
import AuthService from '@/services/auth-service';

export interface AuthContextType {
  isAuthenticated: boolean;
  loginWithEmail: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

interface AuthProviderProps {
  children: ReactNode;
}

 const AuthProvider = ({ children }: AuthProviderProps) => {
  const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
  const authService = AuthService.getInstance();

  useEffect(() => {
    const token = authService.getToken();
    setIsAuthenticated(!!token);
  }, []);

  const loginWithEmail = async (email: string, password: string) => {
    await authService.loginWithEmail(email, password);
    setIsAuthenticated(!!authService.getToken());
  };

  const logout = () => {
    authService.logout();
    setIsAuthenticated(false);
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated, loginWithEmail, logout }}>
      {children}
    </AuthContext.Provider>
  );
};
export default function useAuthData() {
  return useContext(AuthContext);
}
export { AuthContext,AuthProvider };

How the AuthProvider Works

  1. Context Creation: The AuthContext is created to hold the authentication state and methods.

  2. State Management: The AuthProvider component manages the isAuthenticated state using React's useState hook.

  3. Effect Hook: The useEffect hook checks for an existing token when the component mounts, updating the authentication state accordingly.

  4. Login and Logout Methods: The loginWithEmail and logout methods interact with the AuthService instance to manage user authentication.

  5. Context Provider: The AuthContext.Provider wraps the children components, providing them access to the authentication state and methods.

Usage in the Application

In our application, we can access the AuthService instance and the authentication state through the AuthProvider:


import useAuthData from "./auth-provider";

const LoginButton = () => {
  const authData = useAuthData();
 const loginWithEmail= authData?.loginWithEmail
  const handleLogin = async () => {
    if (loginWithEmail) {
      await loginWithEmail('user@example.com', 'password');
    }
  };

  return <button onClick={handleLogin}>Login</button>;
};


export default LoginButton;

This guarantees that all parts of the application that require authentication will interact with the same instance of AuthService, maintaining a consistent state.

Conclusion

The Singleton pattern is a powerful design principle that can help manage shared resources and maintain a consistent state across an application. In our Next.js project, the AuthService class exemplifies how to implement this pattern effectively. By ensuring that only one instance of the authentication service exists, we can streamline our authentication logic and improve the overall architecture of our application.

The integration of the AuthProvider component further enhances this design by leveraging React's context API, allowing for easy access to authentication state throughout the application.

If you're working on a project that requires centralized management of resources or state, consider applying the Singleton pattern along with context management to simplify your design and enhance maintainability.

Repo: https://github.com/Akashjayan1999/Design-pattern

0
Subscribe to my newsletter

Read articles from Akash Jayan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Akash Jayan
Akash Jayan