Architecture de state avec les signals

À retenir — Un service @Injectable qui encapsule des signals privés et n'expose que des lectures (asReadonly, computed) constitue un store réactif simple, testable et sans dépendance externe. linkedSignal gère l'état local qui doit se réinitialiser selon une source.

Vous savez manipuler les signals dans un composant. Voyons maintenant comment structurer l'état d'une application : partager un état entre composants, le garder propre, et connaître les outils avancés (linkedSignal) avant les bonnes pratiques de production.

Le pattern « service de state à signals »

Le moyen le plus simple de partager un état réactif est un service providedIn: 'root'. On y applique le principe d'encapsulation : l'état mutable est privé, on n'expose que des accès en lecture et des méthodes qui décrivent les transitions.

import { Injectable, signal, computed, inject, ChangeDetectionStrategy, Component } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CompteurStore {
  // 1. État privé, seul le store peut l'écrire.
  private readonly _valeur = signal(0);

  // 2. Lectures publiques : signal en lecture seule + valeurs dérivées.
  readonly valeur = this._valeur.asReadonly();
  readonly estPositif = computed(() => this._valeur() > 0);

  // 3. API publique : des intentions, pas des setters bruts.
  incrementer(): void {
    this._valeur.update((n) => n + 1);
  }
  decrementer(): void {
    this._valeur.update((n) => n - 1);
  }
  reinitialiser(): void {
    this._valeur.set(0);
  }
}

N'importe quel composant injecte le store et lit ses signals. Tous les consommateurs restent synchronisés automatiquement :

@Component({
  selector: 'app-affichage',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>
      Valeur partagée : <strong>{{ store.valeur() }}</strong>
      @if (store.estPositif()) { <span>✅</span> }
    </p>
  `,
})
export class AffichageComponent {
  readonly store = inject(CompteurStore);
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [AffichageComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>State partagé entre composants</h1>
    <app-affichage />
    <button (click)="store.decrementer()">−</button>
    <button (click)="store.incrementer()">+</button>
  `,
})
export class AppComponent {
  readonly store = inject(CompteurStore);
}

Essayez en direct

Le bouton et l'affichage sont deux composants distincts reliés par le même store :

state-service

Ce patron couvre une grande partie des besoins. Pour des applications plus larges, des bibliothèques comme NgRx SignalStore formalisent la même idée (état + computed + méthodes) avec des outils supplémentaires (entités, effets, devtools).

Modéliser un état réaliste

Un store de production regroupe souvent plusieurs signals et expose des dérivations métier :

interface Filtre {
  recherche: string;
  seulementActifs: boolean;
}

@Injectable({ providedIn: 'root' })
export class ProduitsStore {
  private readonly _produits = signal<Produit[]>([]);
  private readonly _filtre = signal<Filtre>({ recherche: '', seulementActifs: false });

  readonly produits = this._produits.asReadonly();
  readonly filtre = this._filtre.asReadonly();

  // Vue dérivée : la liste filtrée, recalculée seulement si besoin.
  readonly produitsFiltres = computed(() => {
    const { recherche, seulementActifs } = this._filtre();
    const terme = recherche.trim().toLowerCase();
    return this._produits()
      .filter((p) => !seulementActifs || p.actif)
      .filter((p) => p.nom.toLowerCase().includes(terme));
  });

  readonly nombre = computed(() => this.produitsFiltres().length);

  definirRecherche(recherche: string): void {
    this._filtre.update((f) => ({ ...f, recherche }));
  }
  basculerActifs(): void {
    this._filtre.update((f) => ({ ...f, seulementActifs: !f.seulementActifs }));
  }
}

L'interface ne consomme que produitsFiltres() et nombre() : toute la logique de filtrage est centralisée, testable et réactive.

linkedSignal : un état local lié à une source

Parfois, on a besoin d'un état modifiable par l'utilisateur qui doit pourtant se réinitialiser quand une donnée source change. Exemple : une liste d'options et l'option sélectionnée — si la liste change, la sélection doit revenir au premier élément.

Un signal simple ne suffit pas (il ignore la source) ; un computed non plus (il n'est pas modifiable). La réponse est linkedSignal (Angular 19) :

import { Component, signal, linkedSignal, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>Choix d'une taille</h1>
    @for (option of options(); track option) {
      <button
        (click)="choix.set(option)"
        [style.fontWeight]="choix() === option ? '700' : '400'"
      >
        {{ option }}
      </button>
    }
    <p>Sélection : <strong>{{ choix() }}</strong></p>
    <button (click)="remplacerOptions()">Remplacer la liste d'options</button>
  `,
})
export class AppComponent {
  readonly options = signal(['Petit', 'Moyen', 'Grand']);

  // Modifiable comme un signal, MAIS réinitialisé dès que options() change.
  readonly choix = linkedSignal(() => this.options()[0]);

  remplacerOptions(): void {
    this.options.set(['XS', 'S', 'M', 'L']); // → choix revient à 'XS'
  }
}

Essayez en direct

Sélectionnez une taille, puis remplacez la liste : la sélection se réinitialise.

linked-signal

Tester un store à signals

Tester devient trivial : pas de marbles RxJS, on lit les signals directement.

import { TestBed } from '@angular/core/testing';

describe('CompteurStore', () => {
  it('incrémente la valeur et reflète estPositif', () => {
    const store = TestBed.inject(CompteurStore);

    expect(store.valeur()).toBe(0);
    expect(store.estPositif()).toBe(false);

    store.incrementer();

    expect(store.valeur()).toBe(1);
    expect(store.estPositif()).toBe(true);
  });
});

Bonnes pratiques de production

  • Encapsulez : signal privé _x, exposition via asReadonly() / computed. Jamais de WritableSignal public.
  • Exposez des intentions, pas des setters : ajouterAuPanier(produit) plutôt que panier.set(...) côté composant.
  • Dérivez avec computed, n'« empilez » pas des effects pour synchroniser des signals entre eux.
  • Immuabilité : toujours une nouvelle référence pour tableaux et objets.
  • OnPush + signals dans tous les composants.
  • linkedSignal pour l'état local dépendant d'une source ; resource() pour les données asynchrones (chapitre 7).
  • Pour les très grosses applications, évaluez NgRx SignalStore — mais le service maison suffit dans la majorité des cas.

En résumé

  • Un service à signals (privé + asReadonly/computed + méthodes) est un store réactif simple et testable.
  • Centralisez la logique métier dans des computed.
  • linkedSignal gère l'état local qui se réinitialise selon une source.
  • Encapsulation, immuabilité, OnPush et dérivation sont les piliers d'une architecture saine.

Vous avez parcouru tout le modèle réactif d'Angular : des trois primitives à une architecture de production. Le quiz final valide l'ensemble.

Nous utilisons Microsoft Clarity pour comprendre comment le site est utilisé et l'améliorer. En poursuivant votre navigation, vous l'acceptez. Vous pouvez le désactiver à tout moment.