~6 min de lecture
Angular effect() : les 5 pièges qui attendent tout le monde
effect() paraît simple. Tu lui passes une fonction, elle s'exécute dès qu'un signal change. Deux lignes et c'est plié.
En réalité, effect() est l'une des APIs les plus mal utilisées de l'écosystème Signals. Elle concentre des comportements contre-intuitifs qui surprennent même les devs qui ont pourtant lu la doc : boucles infinies silencieuses, memory leaks dans les services, timing qui contredit ton intuition. Pas parce que l'API est mauvaise — mais parce qu'elle exige de comprendre exactement ce qu'elle fait et ce qu'elle n'est pas censée faire.
Ce guide couvre les 5 pièges les plus fréquents, avec à chaque fois le before/after pour les corriger définitivement.
Valide Angular 17+. Les comportements décrits sont stables depuis l'introduction des Signals en stable (v17).
Piège 1 — Utiliser effect() quand computed() suffit
C'est de loin l'erreur la plus fréquente. Beaucoup de devs viennent de RxJS où on enchaîne les pipe() pour transformer des streams. Naturellement, ils mappent ça en effect() + signal() : le premier écoute, le second stocke le résultat.
Le problème, c'est que tu crées deux signaux là où un seul suffit — et tu forces Angular à gérer un effet de bord (l'écriture dans le second signal) alors qu'une dérivation pure ferait exactement le même travail, plus proprement.
❌ Avant
import { Component, signal, effect } from '@angular/core';
type Item = { price: number; qty: number };
@Component({
selector: 'app-cart',
template: `<p>Total : {{ total() }} €</p>`,
})
export class Cart {
protected readonly items = signal<Item[]>([]);
protected readonly total = signal(0); // un second signal pour stocker la dérivation
constructor() {
effect(() => {
const sum = this.items().reduce((acc, item) => acc + item.price * item.qty, 0);
this.total.set(sum); // écriture dans un signal depuis un effect
});
}
}
✅ Après
import { Component, signal, computed } from '@angular/core';
type Item = { price: number; qty: number };
@Component({
selector: 'app-cart',
template: `<p>Total : {{ total() }} €</p>`,
})
export class Cart {
protected readonly items = signal<Item[]>([]);
protected readonly total = computed(() =>
this.items().reduce((acc, item) => acc + item.price * item.qty, 0)
);
}
computed() est synchrone, mémoïsé et lazy : si items ne change pas entre deux lectures de total, le calcul n'est pas refait. L'effect() n'offre aucune de ces garanties.
Règle d'or : si ton effect() se résume à "lire un signal → écrire dans un autre signal", c'est un computed() déguisé.
Piège 2 — Écrire dans un signal depuis un effect() et créer une boucle infinie
Depuis Angular 19.1, écrire dans un signal depuis un effect() est autorisé sans option particulière — l'ancienne option allowSignalWrites: true est dépréciée et n'a plus d'effet. Angular a relâché la contrainte car les cas légitimes existent.
Ce relâchement rend le piège encore plus sournois : le code compile, tourne, et plante silencieusement. Dès que deux effets se lisent et s'écrivent mutuellement, Angular entre dans une boucle de mise à jour sans fin et te sort un NG0600: Detected cycle in computations — un message que tu ne veux pas voir en production.
❌ Ce qui peut déraper
import { Injectable, signal, effect } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class SyncService {
private readonly a = signal(0);
private readonly b = signal(0);
constructor() {
// Effect 1 : réagit à a, écrit dans b
effect(() => {
this.b.set(this.a() * 2);
});
// Effect 2 : réagit à b, écrit dans a → boucle infinie
effect(() => {
this.a.set(this.b() + 1);
});
}
}
✅ La règle à suivre
Écrire dans un signal depuis un effect() n'est justifié que pour synchroniser une source externe — jamais pour dériver des données entre signals.
import { Injectable, signal, effect } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ThemeService {
readonly theme = signal<'light' | 'dark'>('light');
constructor() {
// Cas légitime : synchroniser avec localStorage (API externe)
effect(() => {
localStorage.setItem('theme', this.theme());
});
// Pas de allowSignalWrites nécessaire : pas d'écriture dans un signal Angular
}
}
Règle d'or : écrire dans un signal depuis un effect() devrait être aussi rare qu'un eslint-disable. Chaque occurrence mérite une explication claire sur pourquoi c'est inévitable.
Piège 3 — L'effect() dans un service qui ne se nettoie jamais
Dans un composant, effect() est automatiquement détruit quand le composant est détruit. Dans un service, la durée de vie dépend de l'injecteur — et un service providedIn: 'root' vit toute la durée de vie de l'application.
Un effect() déclaré dans un service root qui écoute des événements DOM, ouvre des connexions, ou maintient des ressources externes ne se nettoiera jamais tout seul. C'est un memory leak discret, difficile à repérer en développement mais bien réel en production.
❌ Avant
import { Injectable, inject, effect } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })
export class Analytics {
private readonly router = inject(Router);
private readonly navEnd = toSignal(
this.router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
);
constructor() {
// Cet effect vit aussi longtemps que le service — soit toujours
effect(() => {
const event = this.navEnd();
if (event) {
console.log('Page view:', event.url);
}
});
}
}
Pour un service root simple comme celui-ci, le leak est bénin. Mais dès que l'effect ouvre une WebSocket, subscribe à des events externes, ou est dans un service à portée limitée (feature module, lazy route), tu as un vrai problème.
✅ Après
import { Injectable, inject, effect, DestroyRef } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })
export class Analytics {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly navEnd = toSignal(
this.router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
);
constructor() {
const effectRef = effect(() => {
const event = this.navEnd();
if (event) {
console.log('Page view:', event.url);
}
});
this.destroyRef.onDestroy(() => effectRef.destroy());
}
}
effect() retourne un EffectRef avec une méthode .destroy(). Combine-le avec DestroyRef pour un nettoyage garanti quel que soit le contexte d'injection.
Règle d'or : dans un service, toujours conserver le EffectRef retourné et le détruire proprement. Dans un composant, Angular s'en charge automatiquement — tu peux ignorer le retour.
Piège 4 — untracked() : l'outil dont tout le monde abuse
untracked() permet de lire un signal sans l'enregistrer comme dépendance de l'effect. C'est utile quand tu veux réagir uniquement à un signal A tout en lisant la valeur courante d'un signal B — sans que les changements de B déclenchent l'effect.
Le problème : untracked() est souvent utilisé pour "calmer" un effect qui se déclenche trop souvent, sans s'interroger sur la vraie cause. Si tu te retrouves à untracked()-er la moitié de tes lectures dans un même effect, c'est le signal (sans mauvais jeu de mots) que ton design est à revoir.
❌ Usage abusif
import { Component, signal, effect, untracked } from '@angular/core';
@Component({ selector: 'app-example', template: `` })
export class Example {
protected readonly userId = signal('user-1');
protected readonly config = signal({ verbose: false });
protected readonly locale = signal('fr');
protected readonly permissions = signal<string[]>([]);
constructor() {
effect(() => {
const id = this.userId(); // seule vraie dépendance
// Les 3 autres sont "untracké" pour éviter des déclenchements intempestifs
const verbose = untracked(() => this.config().verbose);
const locale = untracked(() => this.locale());
const perms = untracked(() => this.permissions());
console.log(id, verbose, locale, perms);
});
}
}
Pourquoi réagir au changement de userId mais ignorer ceux de config, locale et permissions ? Si ces valeurs influencent le comportement de l'effect, leurs changements devraient aussi le déclencher. Sinon, tu travailles avec des données potentiellement périmées.
✅ Usage légitime
untracked() est justifié pour briser une dépendance réellement non pertinente — un signal de configuration stable lu en lecture seule au moment de l'exécution — ou pour éviter une dépendance circulaire impossible à reformuler autrement.
import { Component, signal, effect, untracked } from '@angular/core';
@Component({ selector: 'app-logger', template: `` })
export class Logger {
protected readonly userId = signal('user-1');
protected readonly debugMode = signal(false); // ne change qu'au boot, jamais en runtime
constructor() {
effect(() => {
const id = this.userId();
// debugMode est lue mais n'a pas de raison de déclencher l'effect
if (untracked(() => this.debugMode())) {
console.log(`[DEBUG] User changed: ${id}`);
}
});
}
}
Règle d'or : si untracked() apparaît plus d'une fois dans un même effect, remets en question le découpage. La plupart du temps, la solution est de séparer l'effect ou de passer à computed().
Piège 5 — Le timing de l'effect() n'est pas synchrone
C'est probablement le piège le plus subtil. Contrairement à computed() qui est calculé immédiatement lors de la lecture, un effect() ne s'exécute pas au moment précis où son signal change. Il est planifié pour s'exécuter lors du prochain cycle de change detection.
En pratique, cela signifie que si tu appelles signal.set() dans un event handler et que tu comptes sur un effect() pour réagir immédiatement dans la même frame d'exécution, tu vas avoir des bugs de timing difficiles à reproduire — et encore plus difficiles à diagnostiquer.
❌ Ce qui ne fonctionne pas comme prévu
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-form',
template: `<button (click)="submit()">Envoyer</button>`,
})
export class Form {
protected readonly isValid = signal(false);
private wasSent = false;
constructor() {
effect(() => {
if (this.isValid()) {
this.sendToApi();
this.wasSent = true;
}
});
}
submit() {
this.isValid.set(true);
// ⚠️ À cet instant, l'effect n'a PAS encore tourné
// this.wasSent est toujours false ici
console.log('Envoyé ?', this.wasSent); // → false
}
private sendToApi() { /* ... */ }
}
✅ Pour les actions déterministes, reste dans le handler
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-form',
template: `<button (click)="submit()">Envoyer</button>`,
})
export class Form {
protected readonly isValid = signal(false);
submit() {
this.isValid.set(true);
if (this.isValid()) {
this.sendToApi(); // exécution synchrone et prévisible
}
}
private sendToApi() { /* ... */ }
}
Réserve l'effect() aux réactions qui s'étalent dans le temps — "à chaque fois que ce signal change, fais X" — pas aux séquences d'actions liées à un event handler précis.
Règle d'or : si tu appelles signal.set() et que tu t'attends à ce qu'un effect() ait tourné dans la ligne suivante, repense ton flow. L'effect() est réactif, pas impératif.
Récap actionnable
Voilà la matrice de décision que tu devrais avoir en tête à chaque fois que tu es tenté d'écrire effect() :
| Situation | Ce qu'il faut utiliser |
|---|---|
| Dériver une valeur à partir d'un ou plusieurs signals | computed() |
| Synchroniser avec une API externe (DOM, localStorage, analytics…) | effect() |
| Réagir à un événement utilisateur de manière déterministe | Handler d'événement direct |
| Lire un signal sans créer de dépendance | untracked() — avec modération |
| Nettoyer un effect dans un service | EffectRef.destroy() + DestroyRef |
L'erreur commune est de traiter effect() comme un "watcher généraliste". Ce n'est pas un watch() Vue.js ou un useEffect() React. C'est un outil spécialisé pour les effets de bord réactifs : intégration avec des APIs externes au monde Angular, synchronisation avec des ressources non-Signal.
Pour tout ce qui est dérivation de données, computed() est non seulement plus simple, mais aussi plus performant grâce à la mémoïsation et à l'évaluation lazy. Pour tout ce qui est séquence d'actions impérative, un handler direct est plus lisible et plus prévisible.
Si tu te retrouves à écrire beaucoup d'effect(), c'est souvent le signe que ton state est mal découpé ou que tu résistes encore à l'approche déclarative des Signals. La bonne nouvelle : une fois ce cap franchi, effect() devient rare — et c'est exactement ce qu'il devrait être.