Services Singleton en Angular : une seule instance partagée

🏷️ Front-end 📅 12/04/2026 04:00:00 👤 Mezgani Said
Angular Service Singleton Injection De Dépendances State Management
Services Singleton en Angular : une seule instance partagée

Maîtriser les services singleton en Angular pour partager l'état et les données entre composants avec providedIn: 'root'.

Qu'est-ce qu'un service singleton et pourquoi l'utiliser ?

Un singleton est un pattern de conception garantissant qu'une classe n'a qu'une seule instance dans toute l'application. En Angular, quand vous injectez un service singleton dans plusieurs composants, ils reçoivent tous la même instance.

À retenir : Un singleton permet de partager l'état et les données entre composants sans avoir besoin de les passer via @Input/@Output. C'est le fondement du state management en Angular.

Usecases des singletons :

  • Gestion d'état : stocker les données utilisateur, les préférences, le panier d'achat
  • Services API : une seule instance pour toutes les requêtes HTTP
  • Authentification : partager le token et les infos utilisateur
  • Logging/Analytics : tracker les événements de l'app
  • Configuration : charger les configs une seule fois au démarrage

Avantages :

  • Une seule instance → moins de mémoire
  • État partagé facilement accessible
  • Évite les props drilling (cascader les props à travers 5 composants)
  • Séparation des préoccupations (logique métier → services)

Créer un service singleton avec providedIn: 'root'

La façon moderne et recommandée en Angular est d'utiliser providedIn: 'root'. Cela garantit que votre service est créé une seule fois et disponible globalement.

// user.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

export interface User {
  id: string;
  name: string;
  email: string;
}

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private userSubject = new BehaviorSubject<User | null>(null);
  public user$ = this.userSubject.asObservable();

  constructor() {
    console.info('UserService instancié une seule fois');
  }

  setUser(user: User): void {
    this.userSubject.next(user);
  }

  getUser(): Observable<User | null> {
    return this.user$;
  }

  clearUser(): void {
    this.userSubject.next(null);
  }
}

Maintenant, injectez ce service dans vos composants :

// component1.component.ts
import { Component, OnInit } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app-component1',
  template: `
    <div>
      <button (click)="setUser()">Définir utilisateur</button>
      <p>{{ (userService.user$ | async)?.name }}</p>
    </div>
  `
})
export class Component1Component implements OnInit {
  constructor(public userService: UserService) {}

  ngOnInit() {
    // Le service est injecté une seule fois
  }

  setUser() {
    this.userService.setUser({ id: '1', name: 'John', email: 'john@example.com' });
  }
}
// component2.component.ts
import { Component } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app-component2',
  template: `
    <div>
      <p v-if="userService.user$ | async as user">
        Utilisateur: {{ user.name }}
      </p>
      <button (click)="logout()">Déconnexion</button>
    </div>
  `
})
export class Component2Component {
  constructor(public userService: UserService) {}

  logout() {
    this.userService.clearUser();
  }
}
Note : Les deux composants partagent la même instance de UserService. Quand Component1 appelle setUser(), Component2 verra la mise à jour.

Différence entre providedIn et providers dans les modules

Avant Angular 6, les singletons étaient déclarés dans les modules. Voici les différences :

Ancienne façon (modules) :

// app.module.ts
import { NgModule } from '@angular/core';
import { UserService } from './user.service';

@NgModule({
  providers: [UserService]  // Singleton au niveau du module
})
export class AppModule {}

Nouvelle façon (providedIn) - RECOMMANDÉE :

// user.service.ts
@Injectable({
  providedIn: 'root'  // Singleton globalement dans l'app
})
export class UserService {}

Différences clés :

Aspect providers providedIn
Scope Au niveau du module Global ou au module
Tree-shaking Pas de tree-shaking Tree-shakable (code mort supprimé)
Maintenance Facile à oublier d'enregistrer Auto-enregistré dans le service
Lazy loading Différent par module Partagé automatiquement

Vous pouvez aussi créer un singleton à l'échelon d'un module spécifique :

// admin.module.ts
@NgModule({
  providers: [AdminService]  // Singleton Admin seulement
})
export class AdminModule {}

Partager l'état entre composants avec un singleton

Voici un exemple complet d'un service singleton gérant un panier d'achat :

// cart.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({
  providedIn: 'root'
})
export class CartService {
  private items$ = new BehaviorSubject<CartItem[]>([]);
  private total$ = new BehaviorSubject<number>(0);

  constructor() {
    this.loadCart();
  }

  addItem(item: CartItem): void {
    const items = this.items$.value;
    const existing = items.find(i => i.id === item.id);

    if (existing) {
      existing.quantity += item.quantity;
    } else {
      items.push(item);
    }

    this.items$.next([...items]);
    this.updateTotal();
  }

  removeItem(id: string): void {
    const items = this.items$.value.filter(i => i.id !== id);
    this.items$.next(items);
    this.updateTotal();
  }

  clearCart(): void {
    this.items$.next([]);
    this.total$.next(0);
  }

  getItems(): Observable<CartItem[]> {
    return this.items$.asObservable();
  }

  getTotal(): Observable<number> {
    return this.total$.asObservable();
  }

  private updateTotal(): void {
    const total = this.items$.value.reduce(
      (sum, item) => sum + (item.price * item.quantity),
      0
    );
    this.total$.next(total);
  }

  private loadCart(): void {
    const saved = localStorage.getItem('cart');
    if (saved) {
      this.items$.next(JSON.parse(saved));
      this.updateTotal();
    }
  }
}

Usage dans les composants :

// shopping.component.ts
export class ShoppingComponent {
  constructor(private cart: CartService) {}

  addProduct() {
    this.cart.addItem({
      id: '1',
      name: 'Produit',
      price: 29.99,
      quantity: 1
    });
  }
}

// checkout.component.ts
export class CheckoutComponent {
  items$ = this.cart.getItems();
  total$ = this.cart.getTotal();

  constructor(private cart: CartService) {}

  proceed() {
    const items = this.items$.value;
    // Procéder au paiement...
  }
}

Éviter les pièges courants des singletons

1. Mutation directe de l'état

❌ MAUVAIS : modifier directement l'objet

// MAUVAIS - crée des bugs difficiles à tracker
this.service.user.name = 'John';  // Changement direct
this.service.notifyObservers();   // Oblige à notifier manuellement

✅ BON : créer une nouvelle copie

// BON - immutable, réactif
const newUser = { ...this.service.user, name: 'John' };
this.userSubject.next(newUser);

2. Oublier de désabonner les subscriptions

❌ MAUVAIS : fuites mémoire

// MAUVAIS - subscription jamais arrêtée
export class Component {
  constructor(private service: MyService) {
    this.service.data$.subscribe(data => {
      this.data = data;
    });
  }
}

✅ BON : utiliser async pipe ou OnDestroy

// BON avec async pipe (auto-désabonnement)
export class Component {
  data$ = this.service.data$;
  constructor(private service: MyService) {}
}

// Ou avec OnDestroy
export class Component implements OnDestroy {
  private destroy$ = new Subject<void>();

  constructor(private service: MyService) {
    this.service.data$
      .pipe(takeUntil(this.destroy$))
      .subscribe(data => this.data = data);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

3. Initialisation tardive

❌ MAUVAIS : données pas prêtes

// MAUVAIS - données non chargées au démarrage
@Injectable({ providedIn: 'root' })
export class ConfigService {
  config: any;

  loadConfig() {
    this.http.get('/config').subscribe(c => this.config = c);
  }
}

✅ BON : initialiser au démarrage de l'app

// app.component.ts
export class AppComponent implements OnInit {
  constructor(private configService: ConfigService) {}

  ngOnInit() {
    this.configService.initialize().subscribe(() => {
      // Config prête
    });
  }
}

Services singleton vs BehaviorSubject pour la réactivité

Les singletons seuls ne suffisent pas pour la réactivité. Combinez-les avec BehaviorSubject ou d'autres Observables :

Service avec état réactif :

// theme.service.ts
@Injectable({ providedIn: 'root' })
export class ThemeService {
  private themeSubject = new BehaviorSubject<'light' | 'dark'>('light');
  public theme$ = this.themeSubject.asObservable();

  toggleTheme(): void {
    const current = this.themeSubject.value;
    const newTheme = current === 'light' ? 'dark' : 'light';
    this.themeSubject.next(newTheme);
    localStorage.setItem('theme', newTheme);
  }

  getTheme(): 'light' | 'dark' {
    return this.themeSubject.value;
  }
}

Utiliser dans un composant :

// app.component.ts
export class AppComponent {
  theme$ = this.themeService.theme$;

  constructor(private themeService: ThemeService) {}

  toggleTheme() {
    this.themeService.toggleTheme();
    // Automatiquement répercuté dans template via async pipe
  }
}

// template
<body [class]="(theme$ | async) === 'dark' ? 'dark-mode' : 'light-mode'">
  <button (click)="toggleTheme()">Toggle Theme</button>
</body>
À retenir : Singleton = une instance. BehaviorSubject/Observable = notifier tous les abonnés des changements. Utilisez les deux ensemble.

Bonnes pratiques et patterns avancés

1. Toujours utiliser providedIn au lieu de providers

Cela permet le tree-shaking et rend le code plus maintenable.

2. Immuabilité et Observables

Évitez les mutations directes. Utilisez BehaviorSubject avec spread operator ou structuredClone().

private updateState(newData: any): void {
  const current = this.stateSubject.value;
  const updated = { ...current, ...newData };
  this.stateSubject.next(updated);
}

3. Séparez les responsabilités

Un service = une responsabilité. Pas un monstre service qui fait tout :

// ✅ BON : un service par domaine
UserService  // gère utilisateurs
AuthService  // gère authentification
CartService  // gère panier

// ❌ MAUVAIS : tout dans un seul service
AppService // user, auth, cart, config, logging...

4. Singletons au niveau module (lazy loading)

Si vous avez un module lazy-loadé, vous pouvez avoir des singletons différents par module :

// admin.module.ts
@NgModule({
  providers: [AdminService]  // Singleton ADMIN seulement
})
export class AdminModule {}

5. Testing des singletons

Utilisez des mocks dans les tests :

// user.service.spec.ts
describe('UserService', () => {
  let service: UserService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(UserService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should update user', (done) => {
    service.setUser({ id: '1', name: 'John', email: 'john@example.com' });
    service.getUser().subscribe(user => {
      expect(user?.name).toBe('John');
      done();
    });
  });
});

6. Debugger les singletons

Utilisez Redux DevTools ou Angular DevTools pour tracer les changements d'état :

// Ajouter un log pour chaque changement d'état
private stateSubject = new BehaviorSubject<State>(initialState);

updateState(newState: State): void {
  console.info('État ancien:', this.stateSubject.value);
  console.info('État nouveau:', newState);
  this.stateSubject.next(newState);
}

7. Singletons avec dépendances circulaires

Évitez que ServiceA dépend de ServiceB qui dépend de ServiceA. Utilisez plutôt un service tiers :

// ✅ BON : éviter la dépendance circulaire
// ServiceA et ServiceB dépendent tous deux de ServiceC
@Injectable()
export class ServiceC {}