Skip to content

State par services — signals, scope, persistance, undo/redo

TL;DR Avant d'ajouter un store, un service avec signals suffit dans 70% des cas. Un service Angular est déjà un container singleton (providedIn: 'root') ou scopé (route/composant), avec injection typée et lifecycle géré. En lui ajoutant des signals comme source de vérité, on obtient un mini-store réactif, type-safe, testable, et sans dépendance externe. Cette note couvre les patterns à adopter (signals d'abord, BehaviorSubject seulement en legacy), les scopes possibles (root, route, composant), la persistance localStorage avec effect, l'undo/redo, et les signaux qui indiquent qu'un store global devient nécessaire.

🧠 Mental model — ASCII + analogie

Un service avec signals est un objet stateful injectable, exactement comme un service classique, mais où chaque champ exposé est un signal lisible. C'est l'équivalent moderne du ViewModel : il porte l'état, expose des dérivés calculés, et offre des méthodes pour muter. La différence avec un store, c'est l'absence de contrat formel (pas d'actions, pas de selectors, pas de DevTools time-travel). On gagne en simplicité, on perd en traçabilité.

        ┌─────────────────────────────────────────────────────────┐
        │              Spectrum de gestion d'état                  │
        └─────────────────────────────────────────────────────────┘

  Local                                                      Global
   │                                                           │
   ▼                                                           ▼
  ┌────────────┐  ┌──────────────┐  ┌─────────────┐  ┌──────────────┐
  │  signal()  │  │ Service avec │  │ SignalStore │  │ NgRx classic │
  │  dans      │  │ signals      │  │ feature     │  │ + DevTools   │
  │ composant  │  │ providedIn   │  │ scoped      │  │ + Effects    │
  │            │  │  root/route  │  │             │  │              │
  └────────────┘  └──────────────┘  └─────────────┘  └──────────────┘
       │                │                  │                  │
   Une seule       Partagé entre      Logique riche,    Multi-équipes,
   vue, jamais    composants,         features          time-travel,
   partagé        peu de logique      composables       contrat strict

L'analogie : un service-signals est un bloc-notes partagé sur le bureau d'une équipe. N'importe qui peut écrire et lire, c'est rapide, mais il n'y a pas d'historique des modifications ni de validation centralisée. Un store NgRx, c'est le registre officiel avec greffier — toute modification passe par une procédure, tout est tracé, mais c'est plus lourd. La question à se poser n'est jamais « store ou pas store », mais « quel niveau de cérémonie est justifié par la complexité du domaine ».

🛠️ Code minimal (ts + html)

Un service-signals classique pour gérer un panier d'achats.

ts
// cart.service.ts
import { computed, effect, inject, Injectable, signal } from '@angular/core';

export interface CartItem {
  readonly productId: string;
  readonly name: string;
  readonly unitPrice: number;
  readonly quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  // state privé en write, exposé en read-only
  private readonly _items = signal<CartItem[]>([]);
  private readonly _coupon = signal<string | null>(null);

  // signals exposés (lecture seule)
  readonly items = this._items.asReadonly();
  readonly coupon = this._coupon.asReadonly();

  // valeurs dérivées mémoïsées
  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0),
  );
  readonly subtotal = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity * item.unitPrice, 0),
  );
  readonly discount = computed(() => (this._coupon() === 'WELCOME10' ? 0.1 : 0));
  readonly total = computed(() => this.subtotal() * (1 - this.discount()));
  readonly isEmpty = computed(() => this._items().length === 0);

  constructor() {
    // persistance automatique
    effect(() => {
      const snapshot = { items: this._items(), coupon: this._coupon() };
      localStorage.setItem('cart', JSON.stringify(snapshot));
    });
    this.restore();
  }

  add(item: Omit<CartItem, 'quantity'>, qty = 1): void {
    this._items.update((items) => {
      const existing = items.find((i) => i.productId === item.productId);
      if (existing) {
        return items.map((i) =>
          i.productId === item.productId ? { ...i, quantity: i.quantity + qty } : i,
        );
      }
      return [...items, { ...item, quantity: qty }];
    });
  }

  remove(productId: string): void {
    this._items.update((items) => items.filter((i) => i.productId !== productId));
  }

  setQuantity(productId: string, quantity: number): void {
    if (quantity <= 0) return this.remove(productId);
    this._items.update((items) =>
      items.map((i) => (i.productId === productId ? { ...i, quantity } : i)),
    );
  }

  applyCoupon(code: string | null): void {
    this._coupon.set(code);
  }

  clear(): void {
    this._items.set([]);
    this._coupon.set(null);
  }

  private restore(): void {
    const raw = localStorage.getItem('cart');
    if (!raw) return;
    try {
      const { items, coupon } = JSON.parse(raw);
      if (Array.isArray(items)) this._items.set(items);
      if (typeof coupon === 'string' || coupon === null) this._coupon.set(coupon);
    } catch {
      // données corrompues, on ignore
    }
  }
}

Consommation simple dans un composant.

ts
// cart-summary.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CartService } from './cart.service';

@Component({
  selector: 'app-cart-summary',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (cart.isEmpty()) {
      <p>Votre panier est vide.</p>
    } @else {
      <p>{{ cart.itemCount() }} article(s)</p>
      <p>Sous-total : {{ cart.subtotal() | currency: 'EUR' }}</p>
      @if (cart.discount() > 0) {
        <p>Remise : -{{ cart.discount() * 100 }}%</p>
      }
      <p><strong>Total : {{ cart.total() | currency: 'EUR' }}</strong></p>
      <button (click)="cart.clear()">Vider</button>
    }
  `,
})
export class CartSummaryComponent {
  protected readonly cart = inject(CartService);
}

Pattern undo/redo via un service générique de signal-stack.

ts
// undo-redo.service.ts
import { computed, signal, WritableSignal } from '@angular/core';

export class UndoRedoState<T> {
  private readonly past = signal<T[]>([]);
  private readonly future = signal<T[]>([]);
  private readonly _present: WritableSignal<T>;

  readonly present = computed(() => this._present());
  readonly canUndo = computed(() => this.past().length > 0);
  readonly canRedo = computed(() => this.future().length > 0);

  constructor(initial: T) {
    this._present = signal(initial);
  }

  set(next: T): void {
    this.past.update((p) => [...p, this._present()]);
    this._present.set(next);
    this.future.set([]); // toute nouvelle action écrase le futur
  }

  undo(): void {
    const past = this.past();
    if (past.length === 0) return;
    const previous = past[past.length - 1];
    this.past.set(past.slice(0, -1));
    this.future.update((f) => [this._present(), ...f]);
    this._present.set(previous);
  }

  redo(): void {
    const future = this.future();
    if (future.length === 0) return;
    const next = future[0];
    this.future.set(future.slice(1));
    this.past.update((p) => [...p, this._present()]);
    this._present.set(next);
  }
}

Pattern legacy avec BehaviorSubject (pour comprendre le code existant).

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

@Injectable({ providedIn: 'root' })
export class LegacyCartService {
  private readonly _items$ = new BehaviorSubject<CartItem[]>([]);

  readonly items$: Observable<CartItem[]> = this._items$.asObservable();
  readonly itemCount$ = this.items$.pipe(
    map((items) => items.reduce((s, i) => s + i.quantity, 0)),
  );

  add(item: CartItem): void {
    this._items$.next([...this._items$.value, item]);
  }
}

Un service-signals route-scoped pour un wizard.

ts
// wizard.service.ts
import { computed, Injectable, signal } from '@angular/core';

interface WizardData {
  step1: { name: string; email: string };
  step2: { address: string; city: string };
  step3: { acceptTerms: boolean };
}

@Injectable() // pas providedIn — fourni via providers de route
export class WizardService {
  private readonly _currentStep = signal(1);
  private readonly _data = signal<Partial<WizardData>>({});

  readonly currentStep = this._currentStep.asReadonly();
  readonly data = this._data.asReadonly();
  readonly isLastStep = computed(() => this._currentStep() === 3);
  readonly canProceed = computed(() => {
    const step = this._currentStep();
    const data = this._data();
    if (step === 1) return !!data.step1?.name && !!data.step1?.email;
    if (step === 2) return !!data.step2?.address;
    if (step === 3) return data.step3?.acceptTerms === true;
    return false;
  });

  updateStep<K extends keyof WizardData>(step: K, value: WizardData[K]): void {
    this._data.update((d) => ({ ...d, [step]: value }));
  }

  next(): void {
    if (this.canProceed()) {
      this._currentStep.update((s) => Math.min(3, s + 1));
    }
  }

  previous(): void {
    this._currentStep.update((s) => Math.max(1, s - 1));
  }

  reset(): void {
    this._currentStep.set(1);
    this._data.set({});
  }
}

// wizard.routes.ts
export const wizardRoutes: Routes = [
  {
    path: 'wizard',
    providers: [WizardService], // scope route
    loadChildren: () => import('./wizard-pages.routes').then((m) => m.wizardPagesRoutes),
  },
];

Migration vers signals avec coexistence (RxJS-interop).

ts
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  template: `<p>{{ count() }}</p>`,
})
class Demo {
  private readonly legacy = inject(LegacyCartService);
  protected readonly count = toSignal(this.legacy.itemCount$, { initialValue: 0 });
}

🎯 Patterns courants

Signal privé + signal public en lecture seule. L'encapsulation classique : _state = signal<T>(...) en privé, state = this._state.asReadonly() exposé. Toute mutation passe par les méthodes publiques, jamais directement depuis l'extérieur. C'est le pattern d'invariant le plus important pour éviter les mutations sauvages.

Computed pour les dérivés. Toute valeur calculée à partir du state doit être un computed. C'est mémoïsé automatiquement, recalculé uniquement quand les dépendances changent, et exposé comme un signal lisible normalement. On ne fait jamais le calcul dans le template ou dans une méthode appelée à chaque CD.

Effect pour les side effects. Pour synchroniser le state avec le monde extérieur (localStorage, sessionStorage, IndexedDB, analytics, API), on utilise effect(() => { ... }) qui s'exécute à chaque changement de signaux dépendants. Attention : un effect doit être créé dans un contexte d'injection (constructor, field initializer) ou avec un Injector explicite.

Scope du service. Par défaut providedIn: 'root' donne un singleton. Pour un state lié à une route, on déclare le service dans les providers de la route. Pour un state lié à un composant, on le met dans providers du composant — il est alors créé et détruit avec le composant. C'est le mécanisme natif d'isolation Angular, sans dépendance externe.

Shared state vs derived state. Le shared state est ce qui est partagé entre composants non parents (panier, utilisateur connecté, notifications). Le derived state est calculé depuis le shared state (total, isLogged, unreadCount). Le derived ne doit jamais être stocké, toujours calculé. C'est la règle qui évite 80% des bugs de cohérence.

Persistance avec restore au démarrage. Le pattern : un effect qui sauvegarde à chaque changement, et un restore() appelé dans le constructor. Toujours valider le JSON au restore (TypeScript ne valide pas le runtime) et gérer les erreurs silencieusement (corruption, version d'app changée).

Migration progressive depuis RxJS. Pour un service legacy avec BehaviorSubject, on peut introduire des signals graduellement : exposer un signal qui suit le BehaviorSubject via toSignal(). Côté composants, on consomme les signals. Côté service, on conserve le subject pour les pipelines existantes. Quand toute la pipeline est convertie, on supprime le subject.

Pattern action implicite. Même sans dispatcher d'actions, on peut nommer les méthodes comme des intentions métier (addToCart, applyCoupon, checkout) plutôt que comme des setters (setItems, setCoupon). Cela donne une trace lisible dans les stack traces et facilite la lecture du code.

Pattern resource() pour le state async. Depuis Angular 19, resource() encapsule un état async avec value, loading, error intégrés. Idéal pour remplacer un BehaviorSubject + loading$ + error$ dans un service.

ts
import { computed, Injectable, resource, signal } from '@angular/core';
// ⚠️ `resource` vit dans @angular/core (PAS @angular/core/rxjs-interop).
// Depuis Angular 20, l'API stable utilise `params` (et non `request`).

@Injectable({ providedIn: 'root' })
export class UsersService {
  private readonly userId = signal<string | null>(null);

  readonly currentUser = resource({
    // `params` remplace l'ancien `request` (renommé en v20).
    params: () => this.userId(),
    loader: async ({ params: id, abortSignal }) => {
      if (!id) return null;
      // abortSignal annule le fetch précédent si userId change pendant le chargement.
      const res = await fetch(`/api/users/${id}`, { signal: abortSignal });
      if (!res.ok) throw new Error('Not found');
      return (await res.json()) as User;
    },
  });

  setUser(id: string): void {
    this.userId.set(id);
  }
}

// utilisation : service.currentUser.value(), service.currentUser.isLoading(), service.currentUser.error()
// `value()` est undefined tant que le loader n'a pas résolu ; gérer le cas dans le template.

Note de version. L'API resource() a évolué : en Angular 19 (preview) le champ s'appelait request, en Angular 20 (stable) il a été renommé params et le loader reçoit { params, abortSignal, previous }. Le abortSignal est ce qui rend resource() supérieur à un BehaviorSubject maison : il annule automatiquement la requête obsolète quand l'input change (race condition « dernier qui répond gagne » éliminée). Pour un service qui consomme un endpoint AI lent, c'est exactement le comportement voulu — voir la section AI plus bas.

Pattern signal de signaux pour les sous-états. Pour des états composés, on peut imbriquer des signals : _state = signal({ form: signal(formData), validation: signal(validationState) }). Plus rare en pratique, mais utile quand on veut une réactivité fine-grained sans recréer l'objet complet.

Pattern "private mutable, public readonly". Le contrat strict d'un service-signals s'écrit en convention : tout signal mutable est préfixé _ (underscore) et privé. Le signal public correspondant est exposé via asReadonly(). Toute mutation passe par les méthodes publiques explicites. Cela rend la surface API claire.

Pattern linkedSignal pour un dérivé mutable. Stable depuis Angular 20, linkedSignal comble le trou entre computed (dérivé mais lecture seule) et signal (mutable mais pas dérivé). Cas typique : une sélection qui se recalcule quand la liste change, mais que l'utilisateur peut aussi override manuellement.

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

private readonly options = signal<string[]>(['a', 'b', 'c']);
// Se réinitialise sur le 1er élément quand options change, MAIS reste écrasable par set().
readonly selected = linkedSignal(() => this.options()[0]);
// l'utilisateur choisit 'c' :
// this.selected.set('c');
// options change → selected revient au premier élément (computation re-exécutée).

C'est la bonne réponse au pitfall « effect qui dérive et écrit un signal » : au lieu d'un effect qui boucle, un linkedSignal exprime « valeur par défaut dérivée, override possible » de manière déclarative et sans cycle.

🔄 Versions — Angular 16 → 20

Angular 16 (mi-2023) : introduction des signals en preview développeur. signal(), computed(), effect() disponibles. toSignal() et toObservable() dans @angular/core/rxjs-interop. Patterns de service-signal commencent à émerger.

Angular 17 (fin 2023) : signals stables. Recommandation officielle d'utiliser signals pour le state local. model() (signal bidirectionnel) introduit en preview. Coexistence parfaite avec RxJS via interop.

Angular 18 (mi-2024) : zoneless ready en preview. Signals devenus le défaut pour le state local et partagé. Apparition de linkedSignal en preview pour les dérivés mutables. effect accepte une option allowSignalWrites pour patterns avancés.

Angular 19 (fin 2024) : zoneless stable. BehaviorSubject officiellement considéré comme legacy pour le state simple — recommandation de migrer vers signals. resource() introduit (preview) pour le state async avec loading/error/value intégrés.

Angular 20 (mi-2025) : linkedSignal stable. resource() stable. Les services Angular sont devenus naturellement signal-based. Outils de migration automatique fournis par Angular CLI (ng update --migrate-state-management).

Trajectoire 2026 : un service-signals est la première brique de state à considérer. NgRx et SignalStore arrivent en complément quand la complexité dépasse le seuil du service simple. Les BehaviorSubject survivent surtout dans le code legacy ou pour des cas spécifiques de pipelines réactives complexes.

⚠️ Pitfalls — 6-10

1. Exposer le signal en écriture. Si on déclare items = signal<T[]>([]) (sans _ privé ni asReadonly()), n'importe quel composant peut appeler service.items.set(...). C'est l'équivalent de public mutable state — l'invariant du service n'est plus garanti. Toujours encapsuler.

2. effect() créé hors contexte d'injection. Un effect doit être créé dans un constructor ou un field initializer. Sinon, il faut passer un Injector explicite : effect(() => {...}, { injector }). Sinon erreur runtime « NG0203: effect() can only be used within an injection context ».

3. Mutations directes dans update(). this._items.update((items) => items.push(newItem)) est cassé : push retourne la longueur, pas le tableau, et mute en place. La référence ne change pas, et le signal ne notifie pas. Toujours retourner un nouveau tableau/objet : (items) => [...items, newItem].

4. localStorage en SSR. Dans un environnement SSR (Angular Universal), localStorage n'existe pas. Toujours guarder avec typeof window !== 'undefined' ou injecter PLATFORM_ID et tester isPlatformBrowser(). Sinon plantage à l'hydratation.

5. Restore avec données obsolètes (versioning). Si le format du state change entre versions de l'app, un restore aveugle peut faire crasher. Ajouter un champ version au snapshot et migrer/jeter les données incompatibles : if (parsed.version !== CURRENT_VERSION) return defaultState.

6. Computed avec side effects. Un computed(() => { localStorage.setItem(...); return ... }) est un anti-pattern. Les computed doivent être purs. Pour les side effects, utiliser effect(). Sinon, l'ordre d'exécution et la fréquence des appels deviennent imprévisibles.

7. Effect qui modifie son propre signal (boucle infinie). effect(() => { this._count.set(this._count() + 1); }) lit _count ET l'écrit : à chaque écriture l'effect se re-planifie → boucle. Important : allowSignalWrites n'existe plus depuis Angular 19 — l'écriture de signaux dans un effect est désormais autorisée par défaut (l'API a été assouplie). Le garde-fou est devenu runtime : Angular détecte les cycles et lève NG0103: Detected a cycle / writes a signal it reads. La bonne réponse n'est jamais un flag, c'est de ne pas dériver dans un effect : pour un état dérivé mutable, utiliser linkedSignal (stable en v20) ; pour un dérivé pur, computed. Un effect ne doit produire que des side effects vers le monde extérieur (DOM, storage, réseau), jamais vers le graphe de signaux.

8. Scope inadapté. Un service providedIn: 'root' qui contient le state d'un wizard temporaire conserve le state entre navigations — surprise au retour sur la page. Toujours réfléchir au scope : root pour le partagé global, route/composant pour le scopé.

9. Subscriptions non nettoyées dans le code legacy. Quand on coexiste avec des BehaviorSubject, ne pas oublier de unsubscribe ou d'utiliser takeUntilDestroyed(). Le passage à toSignal() gère le cleanup automatiquement.

10. Multiplier les services qui se cross-référencent. Plusieurs services qui s'injectent mutuellement (CartServiceUserServiceOrderService) finissent par former un graphe circulaire et un brouillard de responsabilités. C'est le signal qu'il faut centraliser en SignalStore ou en facade.

11. Persistance synchrone qui bloque le main thread. localStorage.setItem(...) est synchrone. Pour des objets très lourds (10+ MB), cela peut figer l'UI. Préférer IndexedDB (via idb-keyval ou dexie) pour les gros volumes, et débounce l'écriture via un effet avec timing.

12. State partagé qui devrait être local. Mettre dans un service providedIn: 'root' un état qui ne concerne qu'une page (filtre d'une seule vue, scroll d'un seul composant) pollue la mémoire et peut créer des bugs de retour de navigation. Réfléchir au scope avant de générer le service.

🔄 Signaux de migration vers un store

Quand passer d'un service-signals vers SignalStore ou NgRx classic ? Quelques indicateurs concrets :

  • Le service dépasse 200-300 lignes avec plusieurs domaines mélangés
  • Plus de 5-10 composants consomment ce service
  • Besoin de DevTools time-travel ou audit trail
  • Logique async coordonnée complexe (polling, WebSocket, retry chains)
  • Plusieurs équipes touchent au même état avec des concerns différents
  • Tests unitaires deviennent complexes à isoler (trop de dépendances)
  • Documentation du service nécessaire pour la maintenance
  • Conflits récurrents lors des merges sur le service

Si trois de ces points s'appliquent, il est temps de migrer vers SignalStore (recommandé) ou NgRx classic (si exigences Redux fortes).

🧭 Comment un staff engineer raisonne sur le state service

Le débat « service vs store » est souvent posé à l'envers. Le bon axe de décision n'est pas la quantité d'état, c'est la nature des invariants et le besoin de traçabilité. Voici la grille mentale.

DimensionService-signals suffitBascule vers store
Topologie des écrituresBeaucoup de lecteurs, peu d'écrivains, écritures localesÉcritures concurrentes depuis plusieurs flux async non coordonnés
InvariantsLocaux, exprimables en computedInter-entités (« le total commande = somme des lignes après remise stock »)
Traçabilitéconsole.log ou Angular DevTools suffisentBesoin d'un journal d'actions reproductible (audit, replay, bug client)
Coordination asyncresource() + effect couvrent le casSagas, annulation en cascade, retry chains, optimistic + rollback
Surface d'équipe1-3 devs, propriété clairePlusieurs équipes, contrat de modification nécessaire
Coût d'un bug d'étatFaible (UI se re-render proprement)Élevé (argent, conformité, données)

Le piège classique du senior junior : choisir l'outil par anticipation (« on prendra NgRx au cas où »). Le coût d'un store prématuré est réel — boilerplate, courbe d'apprentissage, indirection — et il se paie chaque jour, alors que le coût d'une migration tardive se paie une fois, sur un domaine déjà compris. La stratégie staff est donc : commencer service-signals, instrumenter les signaux de migration, migrer un domaine quand 3+ indicateurs s'allument — pas tout le state, juste le domaine concerné. Un store et des services-signals coexistent très bien dans la même app.

Le piège d'égalité (equality) — la cause n°1 de re-renders fantômes

Un signal ne notifie ses consommateurs que si la nouvelle valeur diffère de l'ancienne, selon Object.is par défaut. Conséquences contre-intuitives :

ts
const items = signal<Item[]>([]);
items.set(items());            // même référence → AUCUNE notification (Object.is true)
items.update((a) => { a.push(x); return a; }); // mutation en place → même réf → AUCUNE notification

// Bon : nouvelle référence à chaque mutation
items.update((a) => [...a, x]);

// Pour des objets-valeur où l'égalité structurelle importe :
const filters = signal(defaultFilters, { equal: (a, b) => deepEqual(a, b) });
// → set d'un objet structurellement identique ne re-déclenche PAS les computed/effects en aval.

Le computed se protège déjà en aval : si sa fonction recalcule mais produit une valeur Object.is-égale à la précédente, il ne propage pas. C'est pourquoi subtotal ci-dessus ne fait re-render le template que quand le nombre change réellement, même si _items est remplacé par une liste différente au même prix. Un staff exploite ça : exposer des computed finement découpés (itemCount, subtotal, total) plutôt qu'un gros computed(() => ({ ...everything })) qui invalide tout à chaque frappe.

Glitch-free : pourquoi computed bat un BehaviorSubject + combineLatest

Les signals sont glitch-free et pull-based, lazy. Quand _items et _coupon changent dans la même tâche synchrone, total (qui dépend des deux) n'est recalculé qu'une fois, à la lecture, après stabilisation du graphe. Un pipeline RxJS combineLatest([items$, coupon$]) émet potentiellement un état intermédiaire incohérent (items mis à jour, coupon pas encore) — le fameux glitch. De plus un computed est paresseux : s'il n'est lu par personne (template détruit, aucun consommateur), il ne se recalcule pas du tout. C'est un argument de perf décisif sur des dérivés coûteux.

Untracked, et la frontière de réactivité

Dans un effect ou un computed, toute lecture de signal crée une dépendance. Pour lire une valeur sans s'abonner, on utilise untracked() :

ts
effect(() => {
  const items = this._items();           // dépendance : l'effect re-run si items change
  const sessionId = untracked(() => this._sessionId()); // lu mais PAS suivi
  this.analytics.track('cart_changed', { count: items.length, sessionId });
});

C'est le bon outil quand un effect doit réagir à A mais juste lire B au passage — sans untracked, on créerait des re-runs parasites sur chaque changement de B. Symétriquement, effect(onCleanup => { ... onCleanup(() => sub.unsubscribe()); }) permet de nettoyer une ressource (timer, listener, subscription) avant chaque ré-exécution et à la destruction.

Observabilité d'un state service en prod

Un service-signals n'a pas de DevTools time-travel, mais on peut câbler une observabilité légère sans framework :

ts
constructor() {
  if (!environment.production) {
    effect(() => {
      // Trace chaque transition d'état dans la console groupée — un « poor man's Redux logger ».
      const snapshot = { items: this._items(), coupon: this._coupon() };
      console.debug('[CartService]', structuredClone(snapshot));
    });
  }
  // En prod : échantillonner les mutations critiques vers le monitoring (Sentry breadcrumb),
  // JAMAIS l'état complet (PII, volume). Émettre des événements métier, pas des dumps.
}

Pour un audit reproductible (rejouer un bug client), il faut un journal d'intentions (les méthodes appelées avec leurs args), pas de snapshots — c'est précisément ce qu'un store NgRx donne gratuitement, et l'un des meilleurs arguments de migration.

Le scope est la décision la plus sous-estimée — modèle mental du lifecycle DI

Le bug d'état le plus coûteux dans une app Angular signals n'est presque jamais une mauvaise mutation : c'est un service au mauvais scope. Un providedIn: 'root' vit aussi longtemps que l'application ; un service dans providers de route vit aussi longtemps que la route est montée ; un service dans providers d'un composant naît et meurt avec chaque instance du composant. Ces durées de vie déterminent silencieusement la persistance, les fuites mémoire, et l'isolation entre utilisateurs/onglets.

ScopeDéclarationDurée de vieBon pourPiège classique
root@Injectable({ providedIn: 'root' })Toute l'app (singleton)Auth, thème, panier globalState temporaire qui « colle » entre navigations
routeproviders: [Svc] dans la RouteTant que la route est activeWizard, filtres d'une featureRecréé à chaque entrée — perd l'état au refresh route
composantproviders: [Svc] dans @ComponentUne instance de composantState local isolé, live previewN instances → N états indépendants (souhaité ou bug ?)
environmentprovideX() dans ApplicationConfigApp (alternative root)Config racine, libs

Le mécanisme sous-jacent est l'injecteur hiérarchique : Angular résout un token en remontant l'arbre des injecteurs (composant → route → root → platform). Deux composants frères sous deux routes différentes, chacune fournissant WizardService, obtiennent deux instances distinctes — c'est exactement ce qu'on veut pour isoler deux wizards. Mais si l'on déclare par erreur WizardService en providedIn: 'root', les deux partagent le même état : un utilisateur qui ouvre deux onglets-wizards voit ses saisies se mélanger.

ts
// Anti-pattern : un state de wizard en root « colle » au retour sur la page.
@Injectable({ providedIn: 'root' })   // ❌ singleton
export class WizardService { /* _currentStep persiste entre navigations */ }

// Correct : scope route — recréé propre à chaque entrée dans /wizard.
@Injectable()                          // ✅ pas de providedIn
export class WizardService { /* ... */ }
// + providers: [WizardService] dans la Route (cf. exemple wizard plus haut)

Règle de décision staff : pose-toi « cet état doit-il survivre à la destruction de la vue qui l'a créé ? ». Oui → root. Non, mais partagé entre plusieurs vues d'une feature → route. Non, propre à une instance → composant. Le coût d'un mauvais choix n'est pas un crash bruyant — c'est un bug de cohérence subtil qui n'apparaît qu'au retour de navigation ou en multi-onglets, le pire genre à débugger.

Conséquence sur la persistance. Un service root + effect localStorage = état global partagé entre tous les onglets du même domaine (storage est partagé). Pour un état qui doit rester propre à un onglet, utiliser sessionStorage (par onglet) plutôt que localStorage. Pour un état multi-onglets synchronisé en temps réel (panier visible identique dans deux onglets), écouter l'event storage ou utiliser BroadcastChannel et re-set() le signal — sinon les onglets divergent jusqu'au prochain reload.

Tableau des modes de défaillance — du symptôme à la cause racine

Symptôme observéCause racine probableCorrectif
L'UI ne se met pas à jour après une mutationMutation en place (push/sort) → même réf, Object.is trueImmutabilité : [...], {...}
Re-renders trop fréquents, jankUn gros computed(() => ({ ...tout })) invalidé à chaque frappeDécouper en computed fins et indépendants
NG0103: cycle detectedeffect qui lit ET écrit un signal du même graphecomputed (pur) ou linkedSignal (dérivé mutable)
NG0203: injection contexteffect() créé hors constructor/field initPasser { injector } ou déplacer dans le constructor
État qui « colle » au retour de pageService root au lieu de route/composantRe-scoper (cf. tableau ci-dessus)
Crash à l'hydratation SSRlocalStorage/window lus côté serveurisPlatformBrowser(inject(PLATFORM_ID))
Race condition « réponse obsolète gagne »Fetch maison sans annulationresource() + abortSignal, ou AbortController
Fuite mémoire en streamingSubscription/timer non nettoyés à la destructioneffect(onCleanup => ...) ou DestroyRef.onDestroy

🤖 State service au service d'une UI d'agent IA (streaming, tool-trace, Stop)

C'est le cas réel de ce stack : une UI Angular qui consomme un agent IA servi par NestJS. Le state d'une conversation agentique est exactement un état partagé, dérivé, à réactivité fine — le terrain de jeu idéal d'un service-signals. Trois exigences le rendent non-trivial : (1) le streaming de tokens doit muter un buffer append-only sans recréer tout l'historique à chaque token, (2) la trace d'outils est une union discriminée d'étapes (pending|running|streaming|done|error), (3) un bouton Stop doit annuler côté client et signaler l'annulation au serveur.

ts
// agent-chat.types.ts
export type Role = 'user' | 'assistant';

export interface ChatMessage {
  readonly id: string;
  readonly role: Role;
  readonly content: string;        // accumulé token par token pour l'assistant
}

// Union discriminée — chaque étape d'outil est dans un état explicite.
export type ToolStep =
  | { readonly kind: 'pending'; readonly id: string; readonly name: string }
  | { readonly kind: 'running'; readonly id: string; readonly name: string; readonly input: unknown }
  | { readonly kind: 'streaming'; readonly id: string; readonly name: string; readonly partial: string }
  | { readonly kind: 'done'; readonly id: string; readonly name: string; readonly output: unknown }
  | { readonly kind: 'error'; readonly id: string; readonly name: string; readonly message: string };

export type AgentStatus = 'idle' | 'streaming' | 'cancelling' | 'error';
ts
// agent-chat.service.ts
import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
import { AgentStatus, ChatMessage, ToolStep } from './agent-chat.types';

@Injectable({ providedIn: 'root' })
export class AgentChatService {
  private readonly _messages = signal<ChatMessage[]>([]);
  private readonly _tools = signal<ToolStep[]>([]);
  private readonly _status = signal<AgentStatus>('idle');
  private controller: AbortController | null = null;

  readonly messages = this._messages.asReadonly();
  readonly tools = this._tools.asReadonly();
  readonly status = this._status.asReadonly();
  readonly isStreaming = computed(() => this._status() === 'streaming');
  // Le dernier message assistant, pour le curseur de frappe — computed lazy.
  readonly streamingMessage = computed(() => {
    const last = this._messages().at(-1);
    return last?.role === 'assistant' && this.isStreaming() ? last : null;
  });

  constructor() {
    // Annule la requête en vol si le service (ou l'app) est détruit — pas de fetch fantôme.
    inject(DestroyRef).onDestroy(() => this.controller?.abort());
  }

  async send(prompt: string): Promise<void> {
    if (this._status() === 'streaming') return; // idempotence : une génération à la fois
    this.controller?.abort();
    this.controller = new AbortController();
    this._status.set('streaming');

    const userId = crypto.randomUUID();
    const assistantId = crypto.randomUUID();
    this._messages.update((m) => [
      ...m,
      { id: userId, role: 'user', content: prompt },
      { id: assistantId, role: 'assistant', content: '' },
    ]);

    try {
      const res = await fetch('/api/agent/stream', {
        method: 'POST',
        headers: { 'content-type': 'application/json', 'idempotency-key': assistantId },
        body: JSON.stringify({ prompt, generationId: assistantId }),
        signal: this.controller.signal, // Stop client → abort fetch
      });
      if (!res.body) throw new Error('No stream body');

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      // Boucle de lecture du flux SSE / NDJSON.
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        let nl: number;
        while ((nl = buffer.indexOf('\n')) >= 0) {
          const line = buffer.slice(0, nl).trim();
          buffer = buffer.slice(nl + 1);
          if (line) this.applyEvent(assistantId, JSON.parse(line));
        }
      }
      this._status.set('idle');
    } catch (err) {
      // AbortError = annulation volontaire, pas une erreur métier.
      this._status.set(this.controller?.signal.aborted ? 'idle' : 'error');
    } finally {
      this.controller = null;
    }
  }

  stop(): void {
    if (this._status() !== 'streaming') return;
    this._status.set('cancelling');
    this.controller?.abort();          // annulation CLIENT (fetch coupé)
    // Annulation SERVEUR : signaler au backend d'arrêter la génération (coût LLM).
    void fetch('/api/agent/cancel', {
      method: 'POST',
      body: JSON.stringify({ generationId: this._messages().at(-1)?.id }),
      keepalive: true,                 // survit à la navigation si l'user quitte
    });
  }

  // Le contrat d'événement du serveur — typé, jamais `any` (un `any` ici fait fuiter
  // l'absence de garantie dans tout le reducer et désactive l'exhaustivité du switch).
  private applyEvent(assistantId: string, ev: AgentEvent): void {
    switch (ev.type) {
      case 'token':
        // Passe par le buffer rAF (cf. enqueueToken) plutôt que de muter par token.
        this.enqueueToken(assistantId, ev.text);
        break;
      case 'tool_start':
        this._tools.update((t) => [...t, { kind: 'running', id: ev.id, name: ev.name, input: ev.input }]);
        break;
      case 'tool_result':
        this._tools.update((t) =>
          t.map((s) => (s.id === ev.id ? { kind: 'done', id: ev.id, name: s.name, output: ev.output } : s)),
        );
        break;
      case 'tool_error':
        this._tools.update((t) =>
          t.map((s) => (s.id === ev.id ? { kind: 'error', id: ev.id, name: s.name, message: ev.message } : s)),
        );
        break;
      default:
        // `never` : si un nouveau type d'événement apparaît côté serveur, le compilateur
        // casse ici tant qu'on ne l'a pas géré. C'est le filet de l'union discriminée.
        return ev satisfies never;
    }
  }
}

Le contrat d'événement, lui aussi en union discriminée (à co-localiser dans agent-chat.types.ts) :

ts
export type AgentEvent =
  | { readonly type: 'token'; readonly text: string }
  | { readonly type: 'tool_start'; readonly id: string; readonly name: string; readonly input: unknown }
  | { readonly type: 'tool_result'; readonly id: string; readonly output: unknown }
  | { readonly type: 'tool_error'; readonly id: string; readonly message: string };

Le satisfies never dans le default est le détail staff : il transforme « le serveur a ajouté un événement reasoning_delta que le client ignore silencieusement » (bug invisible en prod) en erreur de compilation. C'est l'argument fort de l'union discriminée sur un objet plat avec champs optionnels.

Pourquoi un service-signals et pas un store ici. Le state d'une conversation est un domaine unique, des écritures séquentielles (un token après l'autre), peu de lecteurs (la vue chat). computed + update immutable suffisent. Un NgRx ajouterait une cérémonie d'actions par token — absurde à la fréquence d'un stream.

Le point de perf critique — coalescer sous zoneless. Sous provideZonelessChangeDetection(), chaque update() planifie une détection de changement. Un LLM rapide émet des centaines de tokens/seconde : muter le signal par token peut saturer le rendu. La parade staff est de bufferiser les tokens et de flusher via requestAnimationFrame — au plus un re-render par frame (~60 fps), peu importe le débit de tokens :

ts
private pending = '';
private rafId: number | null = null;

private enqueueToken(assistantId: string, text: string): void {
  this.pending += text;
  this.rafId ??= requestAnimationFrame(() => {
    const chunk = this.pending;
    this.pending = '';
    this.rafId = null;
    this._messages.update((msgs) =>
      msgs.map((m) => (m.id === assistantId ? { ...m, content: m.content + chunk } : m)),
    );
  });
}

Rendu markdown sûr. Le contenu assistant est du markdown non fiable. Le rendre via [innerHTML] exige de passer par un parser (marked) puis DomSanitizer.sanitize(SecurityContext.HTML, html) — ne jamais utiliser bypassSecurityTrustHtml sur de la sortie LLM (vecteur XSS si le modèle est manipulé par injection de prompt). Idéalement, sanitize en computed mémoïsé pour ne pas re-parser à chaque token (le faire seulement quand le stream se stabilise, pas à chaque frame).

ts
// dans un composant
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';

protected readonly safeHtml = computed<SafeHtml>(() => {
  const md = this.chat.streamingMessage()?.content ?? '';
  return this.sanitizer.sanitize(SecurityContext.HTML, marked.parse(md) as string) ?? '';
});

Côté NestJS (le serveur consommé ici), le pendant de ce client est : un @Sse() ou un Response en chunked qui émet les MessageStream du SDK Anthropic, un client LLM injecté via forRootAsync (jamais new Anthropic() dans un champ — non testable, non configurable), l'AbortController propagé jusqu'au SDK pour couper la génération sur /api/agent/cancel, et une idempotency-key (= generationId) pour qu'un retry réseau ne relance pas une génération payante. Les modèles à viser : claude-opus-4-8 (raisonnement agentique), claude-sonnet-4-6 (équilibre coût/latence), claude-haiku-4-5 (classification/routage rapide). Détails serveur dans les notes NestJS.

🧪 Testing

Tester un service-signals est aussi simple que tester un service classique. On utilise TestBed.inject() et on lit/écrit les signaux directement.

ts
// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';

describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({});
    service = TestBed.inject(CartService);
  });

  it('démarre avec un panier vide', () => {
    expect(service.isEmpty()).toBe(true);
    expect(service.itemCount()).toBe(0);
  });

  it('ajoute un item', () => {
    service.add({ productId: '1', name: 'Café', unitPrice: 3 });
    expect(service.itemCount()).toBe(1);
    expect(service.subtotal()).toBe(3);
  });

  it('incrémente la quantité pour un produit existant', () => {
    service.add({ productId: '1', name: 'Café', unitPrice: 3 });
    service.add({ productId: '1', name: 'Café', unitPrice: 3 }, 2);
    expect(service.items().length).toBe(1);
    expect(service.itemCount()).toBe(3);
  });

  it('applique une remise', () => {
    service.add({ productId: '1', name: 'Livre', unitPrice: 20 });
    service.applyCoupon('WELCOME10');
    expect(service.discount()).toBe(0.1);
    expect(service.total()).toBeCloseTo(18, 2);
  });

  it('persiste dans localStorage', () => {
    service.add({ productId: '1', name: 'Livre', unitPrice: 20 });
    const raw = localStorage.getItem('cart');
    expect(raw).toBeTruthy();
    const data = JSON.parse(raw!);
    expect(data.items.length).toBe(1);
  });
});

Tester un effect demande un flushEffects (via TestBed.tick() ou manuel).

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

it('déclenche l’effet de persistance', () => {
  const service = TestBed.inject(CartService);
  service.add({ productId: '1', name: 'X', unitPrice: 1 });
  TestBed.tick(); // exécute les effects en attente
  expect(localStorage.getItem('cart')).toContain('"productId":"1"');
});

Pour tester l'undo/redo, on peut écrire des sequences linéaires.

ts
describe('UndoRedoState', () => {
  it('undo et redo restaurent les valeurs', () => {
    const state = new UndoRedoState(0);
    state.set(1);
    state.set(2);
    expect(state.present()).toBe(2);
    state.undo();
    expect(state.present()).toBe(1);
    state.undo();
    expect(state.present()).toBe(0);
    state.redo();
    expect(state.present()).toBe(1);
  });

  it('une nouvelle action écrase le futur', () => {
    const state = new UndoRedoState('a');
    state.set('b');
    state.undo();
    state.set('c');
    expect(state.canRedo()).toBe(false);
  });
});

🎬 Cas d'usage concrets

Scénario 1 — E-commerce mode, theme service

Contexte : retailer mode multi-marques avec 4 thèmes différents (marque mère, ligne sport, ligne enfant, ligne luxe), persistance du choix utilisateur, transition smooth entre thèmes, support du mode sombre/clair par OS, et live preview pour le designer en backoffice. Un ThemeService global (providedIn: 'root') avec deux signals (brand: WritableSignal<Brand>, colorScheme: WritableSignal<'light' | 'dark' | 'auto'>), un computed effectiveScheme() qui résout auto via matchMedia, et un effect() qui synchronise vers document.documentElement.dataset + localStorage. Pas besoin de NgRx, pas besoin de SignalStore : trois signaux, deux méthodes (setBrand, setScheme), un effect de persistance. Le service est consommé par 200 composants via inject(ThemeService).brand() — zéro abonnement, zéro async pipe, zéro fuite mémoire. Quand le designer ouvre le live preview, il instancie un ThemeService local au composant preview via providers: [ThemeService], isolé du global.

Scénario 2 — SaaS RH, user prefs

Contexte : préférences utilisateur du SaaS RH (densité d'affichage des tableaux, colonnes visibles par défaut, langue, fuseau, raccourcis clavier personnalisés, sidebar repliée/dépliée). Données simples, peu de logique, lues partout, écrites rarement. Un UserPrefsService avec un seul signal prefs: WritableSignal<UserPrefs> initialisé depuis le backend au boot, persistance hybride : localStorage pour la réactivité immédiate, PATCH /me/prefs debouncé 2s pour la synchronisation serveur. Le service expose des computed dérivés (density(), language(), shortcuts()) et des setters typés (setDensity(d), toggleSidebar()). La fonctionnalité annuler les modifications est implémentée via un previousPrefs signal et une méthode undo() — pas besoin d'undo/redo complet, juste un revert simple. Les tests sont triviaux : on instancie le service avec TestBed, on appelle ses méthodes, on lit ses signaux, sans mock RxJS ni configurer un store.

Scénario 3 — Cabinet juridique, session

Contexte : application interne du cabinet où chaque collaborateur a une session (utilisateur connecté, cabinet courant si multi-cabinet, droits d'accès, timeout idle, signature numérique chargée). Un SessionService (providedIn: 'root') porte le signal currentUser: Signal<User | null>, des computed (isAuthenticated(), permissions(), displayName()), et des méthodes (login(), logout(), refreshToken()). Le timeout idle est géré via un effect() qui écoute les events utilisateur (mouse, keyboard) et qui déclenche logout() après 30 minutes. Les guards de route lisent session.isAuthenticated() directement (pas besoin d'observable). L'intercepteur HTTP injecte le token via session.currentUser()?.token. Pas de NgRx ni SignalStore : un service signals fait le job en 80 lignes, sans dépendance, sans cérémonie, avec un mental model immédiat pour toute l'équipe.

🛠️ Exemple end-to-end

Use case : ThemeService e-commerce mode avec marque, scheme, persistance, transition CSS, et live preview.

ts
// theme.types.ts
export type Brand = 'mother' | 'sport' | 'kids' | 'luxury';
export type Scheme = 'light' | 'dark' | 'auto';
export type EffectiveScheme = 'light' | 'dark';

export interface ThemeState {
  readonly brand: Brand;
  readonly scheme: Scheme;
}

export const DEFAULT_THEME: ThemeState = { brand: 'mother', scheme: 'auto' };
ts
// theme.service.ts
import { computed, effect, inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Brand, DEFAULT_THEME, EffectiveScheme, Scheme, ThemeState } from './theme.types';

const STORAGE_KEY = 'shop.theme';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private readonly _state = signal<ThemeState>(this.readInitial());
  private readonly _osDark = signal(false);

  readonly brand = computed(() => this._state().brand);
  readonly scheme = computed(() => this._state().scheme);
  readonly effectiveScheme = computed<EffectiveScheme>(() => {
    const s = this._state().scheme;
    return s === 'auto' ? (this._osDark() ? 'dark' : 'light') : s;
  });

  constructor() {
    if (this.isBrowser) {
      const mq = window.matchMedia('(prefers-color-scheme: dark)');
      this._osDark.set(mq.matches);
      mq.addEventListener('change', (e) => this._osDark.set(e.matches));

      effect(() => {
        const brand = this.brand();
        const scheme = this.effectiveScheme();
        document.documentElement.dataset['brand'] = brand;
        document.documentElement.dataset['scheme'] = scheme;
        localStorage.setItem(STORAGE_KEY, JSON.stringify(this._state()));
      });
    }
  }

  setBrand(brand: Brand) {
    this._state.update((s) => ({ ...s, brand }));
  }

  setScheme(scheme: Scheme) {
    this._state.update((s) => ({ ...s, scheme }));
  }

  reset() {
    this._state.set(DEFAULT_THEME);
  }

  private readInitial(): ThemeState {
    if (!this.isBrowser) return DEFAULT_THEME;
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      return raw ? { ...DEFAULT_THEME, ...JSON.parse(raw) } : DEFAULT_THEME;
    } catch {
      return DEFAULT_THEME;
    }
  }
}
ts
// theme-switcher.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';

@Component({
  selector: 'app-theme-switcher',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="switcher">
      <label>Marque :
        <select [value]="theme.brand()" (change)="onBrand($any($event.target).value)">
          <option value="mother">Marque mère</option>
          <option value="sport">Sport</option>
          <option value="kids">Enfants</option>
          <option value="luxury">Luxe</option>
        </select>
      </label>
      <label>Thème :
        <select [value]="theme.scheme()" (change)="onScheme($any($event.target).value)">
          <option value="auto">Auto (OS)</option>
          <option value="light">Clair</option>
          <option value="dark">Sombre</option>
        </select>
      </label>
      <p>Affichage effectif : {{ theme.effectiveScheme() }}</p>
    </div>
  `,
  styles: [`.switcher { display: flex; gap: 1rem; align-items: center; }`],
})
export class ThemeSwitcherComponent {
  protected readonly theme = inject(ThemeService);

  onBrand(v: string) { this.theme.setBrand(v as any); }
  onScheme(v: string) { this.theme.setScheme(v as any); }
}
ts
// theme.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';

describe('ThemeService', () => {
  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({});
  });

  it('démarre avec le défaut', () => {
    const t = TestBed.inject(ThemeService);
    expect(t.brand()).toBe('mother');
    expect(t.scheme()).toBe('auto');
  });

  it('change la marque et persiste', () => {
    const t = TestBed.inject(ThemeService);
    t.setBrand('luxury');
    expect(t.brand()).toBe('luxury');
    expect(JSON.parse(localStorage.getItem('shop.theme')!).brand).toBe('luxury');
  });
});

Service singleton, signaux exposés, persistance automatique via effect, tests directs : moins de 100 lignes pour couvrir tout le besoin sans framework de store.

🔁 Quand utiliser / éviter

Utiliser un service-signals quandMigrer vers un store dédié quand
State partagé entre 2-5 composants avec logique simpleState partagé entre 10+ composants avec logique complexe
Domaine bien circonscrit (panier, utilisateur, thème)Plusieurs domaines interconnectés (commandes ↔ panier ↔ stock)
Pas besoin de time-travel debuggingAudit trail ou support nécessitent un historique des changements
Équipe d'une à trois personnes sur la featurePlusieurs équipes contribuent à la même feature
Total < 200 lignes de serviceLe service dépasse 300 lignes et devient illisible
Migration progressive depuis BehaviorSubjectLogique async complexe (sagas, polling coordonné, retry chains)
Wizard temporaire scopé à une routePersistance et restore entre sessions sophistiqués
Apprentissage / projet personnel / MVPApplication enterprise avec exigences de robustesse fortes

🏋️ Exercices

Chaque exercice escalade : on implémente, puis on durcit pour la prod, puis on casse et on répare. Faire les indices uniquement après avoir buté.

Exercice 1 — FavoritesService avec persistance versionnée

Objectif : un service-signals providedIn: 'root' qui gère une liste d'IDs favoris, persistée en localStorage avec un champ version, restaurée au boot, et qui ignore proprement un snapshot d'une version antérieure.

Indice/Solution : signal privé _ids = signal<Set<string>>(new Set()), exposé asReadonly() ; computed count, has(id) retournant un computed-factory ou une simple méthode pure ; effect de persistance écrivant { version: 2, ids: [..._ids()] } (sérialiser le Set en array) ; readInitial() qui parse, vérifie parsed.version === 2, sinon retourne vide. Guarder localStorage avec isPlatformBrowser(inject(PLATFORM_ID)) pour le SSR.

Exercice 2 — Rendre l'undo/redo de niveau prod

Objectif : partir du UndoRedoState<T> de la note et le durcir : (a) limiter l'historique à N entrées (anti fuite mémoire), (b) ajouter un coalesce/debounce pour grouper les frappes rapides en une seule transaction, (c) exposer un computed historySize().

Indice/Solution : (a) dans set(), this.past.update((p) => [...p, prev].slice(-MAX)). (b) garder un timestamp de la dernière set ; si < 300ms et même « type » d'édition, remplacer le sommet de past au lieu d'empiler — ou exposer commit() explicite et accumuler dans _present sans pousser tant que pas commit. (c) historySize = computed(() => this.past().length). Tester le débordement : 1000 set()past().length === MAX.

Exercice 3 — Streaming agent IA avec buffer rAF

Objectif : implémenter AgentChatService.send() qui lit un ReadableStream NDJSON, accumule les tokens via requestAnimationFrame (cf. enqueueToken), et expose messages(), isStreaming(), streamingMessage(). Brancher un bouton Stop qui annule client + serveur.

Indice/Solution : reprendre le code de la section IA. Vérifier que sous zoneless, un stream de 500 tokens ne produit pas 500 détections de changement — instrumenter avec un compteur dans un afterRenderEffect ou un console.count dans le template. Le Stop doit passer _status à cancelling, abort() le controller, et POST /cancel avec keepalive: true.

Exercice 4 (casser puis réparer) — la mutation fantôme

Objectif : reproduire un bug où l'UI ne se met PAS à jour, puis le diagnostiquer.

Indice/Solution : écrire add() avec this._items.update((items) => { items.push(newItem); return items; }). Le template ne bouge pas. Cause : même référence → Object.is true → aucune notification. Réparer avec [...items, newItem]. Variante avancée : _items.set(_items()) (no-op silencieux). Écrire un test qui échoue d'abord (expect(spy).toHaveBeenCalled() sur un effect), puis qui passe après correction immutable.

Exercice 5 (casser puis réparer) — la boucle d'effect

Objectif : provoquer puis corriger un cycle de réactivité.

Indice/Solution : effect(() => this._total.set(this._subtotal() * 1.2))_total est aussi lu ailleurs dans le même graphe → NG0103. La mauvaise réparation est de chercher un flag (allowSignalWrites n'existe plus). La bonne : remplacer l'effect par total = computed(() => this._subtotal() * 1.2). Si le dérivé doit être mutable (l'utilisateur peut override le total puis le reset au calcul), utiliser linkedSignal({ source: this._subtotal, computation: (s) => s * 1.2 }).

Exercice 6 (architecture) — détecter le seuil de migration

Objectif : prendre un OrderService volontairement gonflé (panier + stock + remises + historique commandes, 350 lignes, 12 composants consommateurs) et produire un plan de migration vers SignalStore domaine par domaine, sans big-bang.

Indice/Solution : appliquer la grille de la section « Comment un staff raisonne ». Extraire d'abord le domaine le plus couplé (commandes ↔ stock) en SignalStore avec ses invariants en computed/methods, laisser panier et thème en service-signals. Faire coexister les deux via injection. Écrire le test de non-régression sur les invariants inter-entités AVANT de bouger le code.

🎤 En entretien

Q : Pourquoi un computed est-il préférable à un BehaviorSubject + combineLatest pour un total dérivé ? R : computed est glitch-free (pas d'état intermédiaire incohérent quand deux dépendances changent dans la même tâche), lazy (ne recalcule que s'il est lu), et mémoïsé avec court-circuit d'égalité en aval ; combineLatest peut émettre un état transitoire et recalcule toujours, même sans abonné actif.

Q : Un signal ne notifie pas alors que tu l'as mis à jour. Causes possibles ? R : Mutation en place sans nouvelle référence (push, splice, sort) → Object.is voit la même réf ; ou set() d'une valeur égale à l'actuelle ; ou un equal custom structurel qui considère la nouvelle valeur identique. Réparer par immutabilité ([...], {...}) ou ajuster la fonction d'égalité.

Q : Quand migres-tu un service-signals vers un store, et comment évites-tu un store prématuré ? R : Je migre sur des signaux concrets (3+ : multi-domaines couplés, 10+ consommateurs, besoin d'audit/time-travel, async coordonné complexe), domaine par domaine, pas l'app entière. Le coût d'un store prématuré se paie chaque jour (boilerplate, indirection) ; celui d'une migration tardive se paie une fois sur un domaine déjà compris — donc je commence simple et j'instrumente.

Q : Comment câbler un bouton Stop fiable sur une UI de streaming LLM ? R : Double annulation. Côté client, un AbortController passé à fetch({ signal }) coupe la lecture du stream et libère le rendu. Côté serveur, un POST /cancel (avec keepalive: true pour survivre à la navigation) propage l'abort jusqu'au SDK LLM pour stopper la génération facturée. On distingue AbortError (annulation volontaire, statut idle) d'une vraie erreur (statut error).

Q : Deux composants frères affichent chacun leur propre wizard, mais leurs saisies se mélangent. Diagnostic ? R : Le service de wizard est providedIn: 'root' (singleton partagé) au lieu d'être fourni dans les providers de la route ou du composant. L'injecteur résout le même instance pour les deux. Correctif : retirer providedIn, déclarer providers: [WizardService] au scope route ou composant — l'injecteur hiérarchique crée alors une instance par sous-arbre, et les deux états sont isolés.

Q : Tu rends du markdown produit par un LLM via [innerHTML]. Quel est le risque et comment le neutraliser ? R : XSS par injection de prompt : un attaquant peut amener le modèle à émettre du HTML/JS malveillant. Il faut parser le markdown (marked) puis passer par DomSanitizer.sanitize(SecurityContext.HTML, html) — et ne jamais appeler bypassSecurityTrustHtml sur de la sortie LLM. On mémoïse le sanitize dans un computed et on évite de re-parser à chaque token (sanitize quand le stream se stabilise, pas par frame).

🔗 Liens

  • Documentation Angular signals : https://angular.dev/guide/signals
  • RxJS interop (toSignal, toObservable) : https://angular.dev/guide/signals/rxjs-interop
  • Pattern services-signals (Joshua Morony, Decoded Frontend, Angular University)
  • Migration de BehaviorSubject vers signals : guides communautaires
  • Angular DevTools (inspection des signals) : extension Chrome officielle
  • Article comparatif : « When you actually need a store » (Tim Deschryver, Joshua Morony)

Bibliothèque tech perso — Achref