Deep dive sur les APIs Signals passées stables en Angular 20 : effect(), afterRenderEffect(), signal queries (viewChild, contentChild) et interop RxJS avec toSignal() et toObservable().
Signals en Angular 20 : ce qui est stable
Angular 20 marque un tournant majeur dans l'histoire du framework : plusieurs APIs Signals qui étaient en developer preview depuis Angular 17 sont désormais officiellement stables. Cela signifie que vous pouvez les utiliser en production sans risque de breaking change.
Voici le tableau des APIs concernées et leur statut de stabilité :
| API | Stable depuis | Description courte |
|---|---|---|
signal() |
Angular 17 | Signal primitif en lecture/écriture |
computed() |
Angular 17 | Signal dérivé en lecture seule |
effect() |
Angular 20 | Effet réactif avec cleanup automatique |
afterRenderEffect() |
Angular 20 | Effet post-rendu DOM (canvas, animations) |
viewChild() |
Angular 20 | Signal query sur un enfant du template |
viewChildren() |
Angular 20 | Signal query sur plusieurs enfants |
contentChild() |
Angular 20 | Signal query sur le contenu projeté |
contentChildren() |
Angular 20 | Signal query sur plusieurs contenus projetés |
effect() stable : comportement et injection context
Un effect() est une fonction qui s'exécute automatiquement chaque fois qu'un ou plusieurs signals qu'elle lit changent de valeur. C'est l'équivalent réactif d'un subscribe() en RxJS, mais entièrement synchronisé avec le cycle de détection de changements d'Angular.
Comportement d'un effect()
- Il s'exécute une première fois immédiatement après l'initialisation du composant.
- Il se ré-exécute automatiquement à chaque changement de n'importe quel signal lu à l'intérieur.
- Il est automatiquement détruit quand le composant est détruit (pas besoin de gérer un unsubscribe).
- Il doit être créé dans un injection context (constructeur, ou via
runInInjectionContext()).
// Exemple de base : effect() dans un composant Angular 20
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-compteur',
standalone: true,
template: `
<button (click)="incrementer()">Incrémenter</button>
<p>Valeur : {{ compteur() }}</p>
`
})
export class CompteurComponent {
// Signal primitif initialisé à 0
compteur = signal(0);
constructor() {
// effect() doit être créé dans le constructeur (injection context)
effect(() => {
// Ce code s'exécute à chaque changement de compteur()
console.log('Compteur mis à jour :', this.compteur());
// Ici on peut appeler une API, logger, ou synchroniser un état externe
});
}
incrementer(): void {
// update() modifie le signal en fonction de sa valeur courante
this.compteur.update(val => val + 1);
}
}
Cleanup automatique avec onCleanup
Quand un effect() se ré-exécute ou que le composant est détruit, Angular fournit un callback onCleanup pour nettoyer les ressources (timers, listeners, connexions WebSocket, etc.).
// Nettoyage automatique dans un effect() : cas d'usage setInterval
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-timer',
standalone: true,
template: `<p>Tick : {{ tick() }}</p>`
})
export class TimerComponent {
// Signal qui représente le nombre de ticks
tick = signal(0);
// Signal qui contrôle si le timer est actif
actif = signal(true);
constructor() {
effect((onCleanup) => {
// On lit actif() pour que l'effect se ré-exécute si actif change
if (!this.actif()) return;
// Crée un intervalle qui incrémente tick toutes les secondes
const intervalId = setInterval(() => {
this.tick.update(v => v + 1);
}, 1000);
// onCleanup est appelé automatiquement avant la prochaine exécution
// ou quand le composant est détruit — évite les memory leaks
onCleanup(() => clearInterval(intervalId));
});
}
stopper(): void {
// Passer actif à false re-déclenche l'effect → clearInterval est appelé
this.actif.set(false);
}
}
Créer un effect() hors du constructeur
Si vous devez créer un effect() en dehors du constructeur (dans une méthode ou un service), utilisez runInInjectionContext() :
// Création d'un effect() hors du constructeur avec runInInjectionContext
import { inject, Injector, runInInjectionContext, signal, effect } from '@angular/core';
export class MonService {
private injector = inject(Injector);
private donnees = signal<string[]>([]);
// Méthode appelée dynamiquement (pas dans le constructeur)
activerSuivi(): void {
runInInjectionContext(this.injector, () => {
// Maintenant on est dans un injection context valide
effect(() => {
// Réagit à chaque changement de this.donnees()
console.log('Nouvelles données :', this.donnees());
});
});
}
}
allowSignalWrites qui existait en developer preview a été supprimé. Il est désormais possible d'écrire dans un signal depuis un effect() sans configuration supplémentaire, mais cette pratique reste à utiliser avec précaution pour éviter les boucles infinies.
effect() vs computed() : quelle API choisir
C'est une question fréquente chez les développeurs qui découvrent les Signals Angular. Ces deux APIs sont réactives, mais elles ont des rôles fondamentalement différents.
| Critère | computed() |
effect() |
|---|---|---|
| Retourne une valeur | Oui (signal en lecture seule) | Non (void) |
| Usage principal | Dériver une valeur depuis d'autres signals | Déclencher un effet de bord |
| Utilisation dans le template | Oui, directement {{ maValeur() }} |
Non |
| Exemples d'usage | Total panier, nom complet, liste filtrée | Log, appel API, canvas, localStorage |
| Lazy (exécution différée) | Oui (calculé à la lecture) | Non (s'exécute dès la création) |
La règle de décision en une phrase
computed(). Si vous voulez déclencher une action (log, appel réseau, DOM externe, localStorage), utilisez effect().
// Exemple concret : computed() vs effect() dans le même composant
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-panier',
standalone: true,
template: `
<p>Articles : {{ articles().length }}</p>
<!-- computed() : utilisé directement dans le template -->
<p>Total : {{ totalTTC() }} €</p>
`
})
export class PanierComponent {
// Signal : liste des prix des articles
articles = signal<{ prix: number }[]>([]);
// computed() : dérive le total TTC depuis articles()
// Recalculé automatiquement si articles() change
totalTTC = computed(() =>
this.articles().reduce((sum, a) => sum + a.prix, 0) * 1.2
);
constructor() {
// effect() : synchronise le panier dans localStorage à chaque changement
// Ce n'est pas une valeur dérivée → pas de computed(), mais un effet de bord
effect(() => {
const donnees = JSON.stringify(this.articles());
localStorage.setItem('panier', donnees);
console.log('Panier sauvegardé :', donnees);
});
}
}
afterRenderEffect() : timing post-rendu DOM
afterRenderEffect() est une API spécialisée pour les cas où vous devez interagir avec le DOM après que Angular ait terminé son rendu. C'est le pendant réactif de ngAfterViewInit(), mais avec la puissance des Signals.
Pourquoi un effet post-rendu ?
Certaines opérations nécessitent que le DOM soit entièrement peint avant de s'exécuter :
- Dessiner sur un
<canvas>après que ses dimensions soient calculées. - Initialiser une bibliothèque d'animation (GSAP, Three.js) sur un élément réel.
- Mesurer la hauteur ou la largeur d'un élément avec
getBoundingClientRect(). - Initialiser un éditeur de texte riche (CodeMirror, Quill) dans un conteneur Angular.
effect() s'exécute dans le cycle de détection de changements Angular. afterRenderEffect() s'exécute après que le navigateur ait effectivement peint le DOM, comme un requestAnimationFrame intelligent.
// afterRenderEffect() : dessiner sur un canvas après chaque mise à jour
import { Component, signal, viewChild, ElementRef, afterRenderEffect } from '@angular/core';
@Component({
selector: 'app-graphique',
standalone: true,
template: `
<!-- Signal query : référence réactive au canvas -->
<canvas #monCanvas width="400" height="200"></canvas>
<button (click)="changerCouleur()">Changer couleur</button>
`
})
export class GraphiqueComponent {
// viewChild() signal : accès réactif à l'élément canvas du template
monCanvas = viewChild.required<ElementRef<HTMLCanvasElement>>('monCanvas');
// Signal qui contrôle la couleur du graphique
couleur = signal('#3b82f6');
constructor() {
afterRenderEffect(() => {
// S'exécute APRES que le DOM est peint — le canvas a ses dimensions réelles
const canvas = this.monCanvas().nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Efface le canvas avant de redessiner
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Dessine un rectangle avec la couleur courante du signal
ctx.fillStyle = this.couleur();
ctx.fillRect(20, 20, 360, 160);
// Ajoute un texte centré
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px Manrope, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Graphique Angular 20', canvas.width / 2, canvas.height / 2);
});
}
changerCouleur(): void {
// Modifie couleur() → afterRenderEffect() se ré-exécute après le prochain rendu DOM
const couleurs = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
const idx = couleurs.indexOf(this.couleur());
this.couleur.set(couleurs[(idx + 1) % couleurs.length]);
}
}
afterRenderEffect() avec phases de rendu
Angular 20 expose plusieurs phases pour afterRenderEffect(), permettant de contrôler l'ordre d'exécution des effets :
// afterRenderEffect() avec phases : read puis write (évite le layout thrashing)
import { afterRenderEffect, AfterRenderPhase, ElementRef, viewChild } from '@angular/core';
// BONNE PRATIQUE : séparer les lectures DOM (read) et les écritures DOM (write)
// pour éviter le "layout thrashing" (alternance coûteuse read/write)
afterRenderEffect({
// Phase 1 : mesurer (ne pas modifier le DOM ici)
read: () => {
const el = this.monElement().nativeElement;
// Lecture de la hauteur réelle — opération coûteuse si mélangée aux écritures
this.hauteurMesuree = el.getBoundingClientRect().height;
},
// Phase 2 : écrire (modifier le DOM après toutes les lectures)
write: () => {
// Utilise la hauteur mesurée pour ajuster un autre élément
this.autreElement().nativeElement.style.height = `${this.hauteurMesuree}px`;
}
});
afterRenderEffect() ne s'exécute que côté navigateur. En SSR (Angular Universal), l'effet est automatiquement ignoré, ce qui évite les erreurs de type "document is not defined".
Signal queries : viewChild() et contentChild()
Les signal queries remplacent les décorateurs @ViewChild, @ViewChildren, @ContentChild et @ContentChildren. Elles exposent les références DOM et les instances de composants sous forme de signals, ce qui les rend naturellement réactives.
viewChild() et viewChildren()
viewChild() accède à un élément ou composant directement dans le template du composant courant.
// viewChild() et viewChildren() : accès réactif aux éléments du template
import { Component, viewChild, viewChildren, ElementRef, QueryList } from '@angular/core';
import { MonEnfantComponent } from './mon-enfant.component';
@Component({
selector: 'app-parent',
standalone: true,
imports: [MonEnfantComponent],
template: `
<!-- Référence nommée pour viewChild() -->
<input #champRecherche type="text" placeholder="Rechercher...">
<!-- Plusieurs instances pour viewChildren() -->
<app-mon-enfant *ngFor="let item of items" [data]="item"></app-mon-enfant>
`
})
export class ParentComponent {
// viewChild() : signal nullable — renvoie undefined si l'élément n'existe pas
champRecherche = viewChild<ElementRef<HTMLInputElement>>('champRecherche');
// viewChild.required() : signal non-nullable — erreur si l'élément est absent
// Utilisez .required() quand l'élément est toujours présent dans le template
champRequired = viewChild.required<ElementRef<HTMLInputElement>>('champRecherche');
// viewChildren() : signal qui retourne un tableau de toutes les instances
enfants = viewChildren(MonEnfantComponent);
focuserChamp(): void {
// Accès via .() — signal nullable, donc vérifier undefined
this.champRecherche()?.nativeElement.focus();
}
compterEnfants(): number {
// viewChildren() retourne un Signal<readonly MonEnfantComponent[]>
return this.enfants().length;
}
}
contentChild() et contentChildren()
contentChild() accède aux éléments projetés dans le composant via <ng-content>.
// contentChild() : accès réactif au contenu projeté (ng-content)
import { Component, contentChild, contentChildren, ElementRef } from '@angular/core';
import { BoutonComponent } from './bouton.component';
@Component({
selector: 'app-carte',
standalone: true,
template: `
<div class="carte">
<!-- Le contenu projeté arrive ici -->
<ng-content></ng-content>
</div>
`
})
export class CarteComponent {
// contentChild() : accède au premier BoutonComponent projeté dans ng-content
boutonPrincipal = contentChild(BoutonComponent);
// contentChildren() : accède à tous les BoutonComponent projetés
tousLesBoutons = contentChildren(BoutonComponent);
// Exemple d'utilisation : réagir aux changements du contenu projeté
constructor() {
// effect() qui réagit si le bouton principal change
effect(() => {
const btn = this.boutonPrincipal();
if (btn) {
console.log('Bouton principal projeté trouvé :', btn);
}
});
}
}
Différences avec les anciens décorateurs
| Ancienne API (décorateurs) | Nouvelle API (signal queries) | Avantage |
|---|---|---|
@ViewChild('ref') |
viewChild('ref') |
Réactif, pas besoin de ngAfterViewInit |
@ViewChildren(Comp) |
viewChildren(Comp) |
Signal Array, toujours à jour |
@ContentChild(Dir) |
contentChild(Dir) |
Réactif au changement de projection |
@ContentChildren(Dir) |
contentChildren(Dir) |
Signal Array, mis à jour automatiquement |
@ViewChild et @ContentChild restent fonctionnels en Angular 20. La migration vers les signal queries peut se faire progressivement, fichier par fichier.
toSignal() et toObservable() : interop RxJS
Dans la réalité, la plupart des applications Angular existantes utilisent massivement RxJS. Angular fournit deux fonctions pour faire le pont entre les Observables et les Signals, permettant une migration progressive sans tout réécrire.
toSignal() : convertir un Observable en Signal
toSignal() souscrit à un Observable et expose sa dernière valeur sous forme de signal. La souscription est automatiquement nettoyée quand le composant est détruit.
// toSignal() : transformer un Observable HTTP en Signal pour le template
import { Component, inject, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
interface Produit {
id: number;
nom: string;
prix: number;
}
@Component({
selector: 'app-liste-produits',
standalone: true,
template: `
<!-- Pas de async pipe nécessaire — le signal gère la souscription -->
<p *ngIf="chargement()">Chargement...</p>
<ul>
<li *ngFor="let p of produits()">{{ p.nom }} — {{ p.prix }} €</li>
</ul>
<p>Total produits : {{ nombreProduits() }}</p>
`
})
export class ListeProduitsComponent {
private http = inject(HttpClient);
// toSignal() convertit l'Observable en signal
// initialValue évite la valeur undefined au premier rendu
produits = toSignal(
this.http.get<Produit[]>('/api/produits'),
{ initialValue: [] as Produit[] }
);
// computed() peut dériver des valeurs depuis un signal créé par toSignal()
nombreProduits = computed(() => this.produits().length);
// Exemple avec un signal de chargement distinct
chargement = toSignal(
this.http.get<Produit[]>('/api/produits').pipe(
// map() : true pendant le chargement, false après
// En pratique, utiliser un BehaviorSubject pour un vrai état de chargement
),
{ initialValue: true }
);
}
toSignal() avec gestion d'erreur
// toSignal() avec gestion d'erreur : comportement à l'échec de l'Observable
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, of } from 'rxjs';
// Option 1 : fournir une valeur de repli en cas d'erreur
const utilisateur = toSignal(
this.userService.getUser(id).pipe(
// catchError intercepte l'erreur et retourne une valeur de repli
catchError(() => of(null))
),
{ initialValue: null }
);
// Option 2 : laisser l'erreur se propager (le signal prend la valeur undefined)
// et gérer l'erreur via un try/catch dans l'effect()
const config = toSignal(
this.configService.getConfig(),
{
initialValue: null,
// rejectErrors: true → le signal throw si l'Observable error
// rejectErrors: false (défaut) → la valeur reste undefined en cas d'erreur
}
);
toObservable() : convertir un Signal en Observable
toObservable() fait l'inverse : il crée un Observable qui émet chaque fois que le signal change. Utile pour combiner des Signals avec des pipelines RxJS complexes (debounce, switchMap, etc.).
// toObservable() : pipeline RxJS sur un signal de recherche
import { Component, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, switchMap, distinctUntilChanged } from 'rxjs';
@Component({
selector: 'app-recherche',
standalone: true,
template: `
<input
[value]="terme()"
(input)="terme.set($event.target.value)"
placeholder="Rechercher un article..."
>
<ul>
<li *ngFor="let r of resultats()">{{ r.titre }}</li>
</ul>
`
})
export class RechercheComponent {
private http = inject(HttpClient);
// Signal : terme de recherche tapé par l'utilisateur
terme = signal('');
// toObservable() convertit le signal terme en Observable
// Puis on applique un pipeline RxJS classique avec debounce et switchMap
private resultats$ = toObservable(this.terme).pipe(
// Attend 300ms après la dernière frappe avant de lancer la requête
debounceTime(300),
// Évite les requêtes redondantes si la valeur n'a pas changé
distinctUntilChanged(),
// switchMap annule la requête précédente si une nouvelle arrive
switchMap(terme =>
terme.length >= 2
? this.http.get<{ titre: string }[]>(`/api/recherche?q=${terme}`)
: []
)
);
// Conversion finale en signal pour l'utilisation dans le template
resultats = toSignal(this.resultats$, { initialValue: [] });
}
toSignal() et toObservable() doivent être appelés dans un injection context (constructeur ou champ de classe). Si vous les appelez dans une méthode, utilisez runInInjectionContext().
Anti-patterns et erreurs courantes
Les Signals Angular 20 sont puissants mais peuvent conduire à des bugs difficiles à tracer si certaines pratiques sont mal comprises. Voici les pièges les plus fréquents.
1. Boucle infinie dans un effect()
Écrire dans un signal qui est lu par le même effect() crée une boucle infinie. Angular 20 détecte ces cycles et lance une erreur, mais le problème doit être corrigé à la source.
// ANTI-PATTERN : écriture dans un signal lu par le même effect() → boucle infinie
const compteur = signal(0);
effect(() => {
console.log(compteur()); // Lit compteur()
compteur.set(compteur() + 1); // Écrit dans compteur() → ré-déclenche l'effect → boucle !
});
// CORRECTION : utiliser computed() pour les valeurs dérivées
// ou séparer la logique dans deux effects distincts avec des signals différents
const base = signal(0);
const double = computed(() => base() * 2); // Pas d'effet de bord, pas de boucle
2. Créer un effect() en dehors d'un injection context
// ANTI-PATTERN : appeler effect() dans une méthode ordinaire
export class MonComponent {
demarrer(): void {
// ERREUR : "effect() can only be used within an injection context"
effect(() => console.log('valeur'));
}
}
// CORRECTION : créer l'effect() dans le constructeur
export class MonComponent {
constructor() {
// OK : le constructeur est un injection context
effect(() => console.log('valeur'));
}
}
3. Utiliser effect() à la place de computed()
// ANTI-PATTERN : utiliser effect() pour calculer une valeur dérivée
export class MonComponent {
prix = signal(100);
quantite = signal(3);
total = signal(0); // Signal inutile — la valeur doit être dérivée
constructor() {
// Mauvais usage : effect() pour dériver total
// → décalage potentiel d'un cycle entre la mise à jour et la lecture
effect(() => {
this.total.set(this.prix() * this.quantite());
});
}
}
// CORRECTION : utiliser computed() — synchrone, toujours cohérent
export class MonComponent {
prix = signal(100);
quantite = signal(3);
// computed() recalcule total() immédiatement et de manière synchrone
total = computed(() => this.prix() * this.quantite());
}
4. Négliger le cleanup dans les effects avec ressources externes
// ANTI-PATTERN : event listener non nettoyé dans un effect()
effect(() => {
const valeur = this.monSignal();
// PROBLÈME : un nouveau listener est ajouté à chaque exécution de l'effect
// sans supprimer le précédent → accumulation de listeners, memory leak
document.addEventListener('keydown', (e) => console.log(valeur, e.key));
});
// CORRECTION : utiliser onCleanup pour supprimer le listener précédent
effect((onCleanup) => {
const valeur = this.monSignal();
const handler = (e: KeyboardEvent) => console.log(valeur, e.key);
document.addEventListener('keydown', handler);
// onCleanup : appelé avant la prochaine exécution ou destruction du composant
onCleanup(() => document.removeEventListener('keydown', handler));
});
5. Lire un viewChild() avant que le DOM soit initialisé
// ANTI-PATTERN : accéder à viewChild() trop tôt (hors injection context ou effect)
export class MonComponent {
monInput = viewChild<ElementRef>('monInput');
constructor() {
// PROBLÈME : le DOM n'est pas encore rendu dans le constructeur
// monInput() retourne undefined ici
this.monInput()?.nativeElement.focus(); // Ne fonctionne pas
}
}
// CORRECTION : utiliser effect() ou afterRenderEffect() pour attendre le DOM
export class MonComponent {
monInput = viewChild<ElementRef>('monInput');
constructor() {
afterRenderEffect(() => {
// Le DOM est peint — monInput() est maintenant défini
this.monInput()?.nativeElement.focus();
});
}
}
@angular-eslint) — certaines détectent automatiquement les mauvaises pratiques liées aux Signals, comme l'écriture dans un signal depuis un effect().
Conclusion
Angular 20 concrétise la vision réactive du framework en stabilisant les APIs Signals les plus attendues. Avec effect() pour les effets de bord, afterRenderEffect() pour les interactions DOM post-rendu, les signal queries pour accéder aux éléments du template, et l'interop RxJS via toSignal() et toObservable(), vous disposez d'une boîte à outils complète et cohérente pour écrire des composants modernes, réactifs et maintenables.
La migration vers ces APIs peut se faire progressivement : rien ne vous oblige à tout réécrire d'un coup. Commencez par identifier les composants où vous utilisez @ViewChild avec ngAfterViewInit(), ou ceux qui ont des abonnements RxJS non nettoyés — ce sont les candidats idéaux pour un premier passage aux Signals stables.
computed() pour les valeurs dérivées, effect() pour les effets de bord, afterRenderEffect() pour les interactions DOM post-rendu, et toSignal() / toObservable() pour une migration en douceur depuis RxJS. Ces quatre règles couvrent 90% des cas d'usage quotidiens avec les Signals Angular 20.