effect() exécute une fonction une fois, puis à nouveau chaque fois qu’un signal qu’elle a lu change. C’est le pont entre le monde réactif (les signals) et le monde qui ignore tout des signals : DOM manuel, localStorage, bibliothèques tierces, analytics, la console.
Un effect fait passer des données des signals vers l’extérieur. Si vous l’utilisez pour modifier un autre signal, il vous fallait presque certainement un computed() ou un linkedSignal().
Syntaxe
Vous créez l’effect dans un contexte d’injection (le constructeur ou l’initialiseur d’un champ). L’effect est automatiquement lié au cycle de vie du composant ou du service : quand celui-ci est détruit, l’effect l’est aussi.
import { Component, effect, signal } from '@angular/core';
@Component({ /* ... */ })
export class Settings {
readonly theme = signal<'light' | 'dark'>('light');
constructor() {
// S’exécute une fois à la création, puis chaque fois que `theme` change.
effect(() => {
document.documentElement.dataset['theme'] = this.theme();
localStorage.setItem('theme', this.theme());
});
}
}Quand s’exécute-t-il exactement ?
- Pas de façon synchrone : si vous modifiez un signal trois fois de suite, l’effect s’exécute une seule fois, avec la valeur finale.
- Les effects de composant s’exécutent pendant la Change Detection, avant la vérification du template du composant.
- Les effects dans les services root s’exécutent comme des microtâches, avant la Change Detection.
- Les dépendances sont dynamiques : seuls les signals lus lors de la dernière exécution comptent.
Nettoyage : onCleanup
L’effect reçoit une fonction onCleanup. Le callback que vous enregistrez s’exécute avant l’exécution suivante et à la destruction. C’est là que vous arrêtez les minuteurs, désabonnez les écouteurs, annulez les requêtes.
effect((onCleanup) => {
if (!this.running()) return;
const id = setInterval(() => this.ticks.update((t) => t + 1), this.speed());
// Appelé avant la prochaine exécution de l’effect
// et quand le composant / service est détruit.
onCleanup(() => clearInterval(id));
});- Cliquez sur « Démarrer ». Changez l’intervalle pendant l’exécution.
afterNextRender & afterRenderEffect : quand vous avez besoin du DOM
Si vous devez mesurer ou modifier le DOM après qu’Angular l’a mis à jour, utilisez les render hooks. afterNextRender ne s’exécute qu’une fois (parfait pour initialiser une bibliothèque de graphiques ou de cartes), et afterRenderEffect est un effect qui s’exécute après le rendu, uniquement quand ses signals ont changé. Les deux s’exécutent uniquement dans le navigateur (pas sur le serveur pendant le SSR).
import { afterNextRender, afterRenderEffect, ElementRef, viewChild } from '@angular/core';
export class AutoGrowTextarea {
private readonly area = viewChild.required<ElementRef<HTMLTextAreaElement>>('area');
readonly text = signal('');
constructor() {
// S’exécute APRÈS qu’Angular a mis à jour le DOM, chaque fois que `text` change.
afterRenderEffect(() => {
this.text();
const el = this.area().nativeElement;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
// Une seule fois, après le premier rendu (idéal pour les bibliothèques qui ont besoin du DOM).
afterNextRender(() => {
this.chart = new ThirdPartyChart(this.canvas().nativeElement);
});
}
}
// Avec des phases explicites — évite le « layout thrashing » quand vous avez beaucoup de composants :
afterRenderEffect({
earlyRead: () => this.host.nativeElement.getBoundingClientRect().width, // lit le DOM
write: (width) => { this.panel().nativeElement.style.width = `${width()}px`; }, // écrit dans le DOM
});Anti-patterns (et quoi utiliser à la place)
1. Synchroniser des signals entre eux
// ✓ computed pour l’état dérivé
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
// ✓ linkedSignal si la valeur dérivée doit aussi être modifiable
readonly selected = linkedSignal(() => this.options()[0]);// ✗ effect utilisé pour « synchroniser » l’état
readonly firstName = signal('Ana');
readonly lastName = signal('Pop');
readonly fullName = signal('');
constructor() {
effect(() => this.fullName.set(`${this.firstName()} ${this.lastName()}`));
}
// Problèmes : une exécution de plus, un moment où fullName est périmé,
// et un graphe de dépendances difficile à suivre.firstName.set(…)computed()Ana Popeffect() + set()Ana Popexécutions de l’effet : 1À l’écran, les deux sont identiques : l’effet d’un composant s’exécute pendant la détection des changements, avant la mise à jour de son template. La différence apparaît dans le code qui lit la valeur juste après set().
Lu dans le même gestionnaire de clic, juste après set()
- Cliquez sur un prénom : vous voyez ce que chaque variante renvoie à cet instant précis.
2. Récupérer des données depuis un effect
// ✓ resource / httpResource : annulation automatique, statut, erreur, rechargement
readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);// ✗ HTTP déclenché depuis un effect
effect(() => {
const id = this.userId();
this.http.get<User>(`/api/users/${id}`).subscribe((u) => this.user.set(u));
// pas d’annulation : d’anciennes réponses peuvent arriver après les nouvelles (race condition)
});Le contexte d’injection
effect() a besoin d’un injecteur pour savoir quand se détruire. En dehors du constructeur, vous obtenez l’erreur NG0203. La solution : créer l’effect comme champ ou dans le constructeur ; dans de rares cas, passez explicitement l’injector.
export class Dashboard {
private readonly injector = inject(Injector);
// ✓ dans l’initialiseur d’un champ ou le constructeur : le contexte d’injection est disponible
private readonly logEffect = effect(() => console.log(this.filter()));
ngOnInit() {
// ✗ ici, vous n’êtes plus dans un contexte d’injection → erreur NG0203
// effect(() => ...);
// ✓ si vous en avez vraiment besoin plus tard, passez l’injector explicitement
effect(() => console.log(this.filter()), { injector: this.injector });
}
stop() {
this.logEffect.destroy(); // arrêt manuel (rarement nécessaire — il se détruit avec le composant)
}
}Avant d’écrire un effect, vérifiez s’il ne s’agit pas d’un état dérivé (signal() et computed()) ou d’un état qui se réinitialise (linkedSignal() — état dérivé modifiable). Pourquoi un effect s’exécute exactement à ce moment-là : Les Signals sous le capot.
Quand OUI, quand NON
- Persistance :
localStorage,sessionStorage, IndexedDB. - Logging, analytics, télémétrie.
- Bibliothèques impératives : graphiques (Chart.js), cartes (Leaflet), éditeurs, canvas.
- APIs du navigateur :
document.title, focus, scroll, classes sur<html>. - Synchronisation avec du code non-Angular (un ancien store, un widget jQuery).
- État dérivé →
computed(). - État dérivé modifiable / réinitialisable →
linkedSignal(). - Requêtes quand un paramètre change →
resource()/httpResource(). - Réagir à un clic → mettez la logique directement dans le gestionnaire d’événement.
- Validation de formulaire → Signal Forms a ses propres règles.
Pièges fréquents
- Boucles infinies : un effect qui lit et écrit le même signal peut se redéclencher sans fin. Utilisez
untracked()pour lire la valeur courante ou, mieux, repensez le flux. - Dépendances cachées : si vous appelez une méthode qui lit des signals en interne, ceux-ci deviennent aussi des dépendances. Enveloppez l’appel dans
untracked(() => ...). - Attendre une exécution synchrone : après
signal.set(), l’effect n’a PAS encore été exécuté. N’écrivez pas de code qui en dépend. - effect dans ngOnInit : erreur de contexte d’injection. Déplacez-le dans le constructeur.
Aide-mémoire
effect(() => { /* lit des signals, produit des effets de bord */ });
effect((onCleanup) => { const id = setInterval(...); onCleanup(() => clearInterval(id)); });
effect(fn, { injector, manualCleanup: true, debugName: 'sync-storage' });
const ref = effect(fn); ref.destroy();
afterNextRender(() => { /* une fois, après le premier rendu — initialise une bibliothèque DOM */ });
afterRenderEffect(() => { /* après chaque rendu où les signals lus ont changé */ });
afterRenderEffect({ earlyRead, write, mixedReadWrite, read });
// Règle : effect = « monde réactif → monde non réactif ». Jamais signal → signal.