~12 min de lecture
afterRenderEffect() : le hook Angular 20 qui remplace tes effect() qui touchent au DOM
TL;DR
Un effect() qui lit ou écrit le DOM s'exécute pendant le cycle de change detection : il voit un état intermédiaire et
alterne lectures et écritures, ce qui force le navigateur à recalculer le layout en cascade. afterRenderEffect(), stable depuis Angular 20,
garde le suivi des signals mais tourne une fois le DOM du tick écrit, avec quatre phases (earlyRead, write,
mixedReadWrite, read) qui séparent les lectures des écritures à l'échelle de tout l'arbre. Ça ne concerne que le
navigateur : le hook est ignoré au rendu serveur, et un effect() qui ne touche pas au DOM reste un effect().
Tu as un composant qui affiche un graphique. Les données arrivent dans un signal(). Tu veux redessiner le graphique à chaque changement. Réflexe :
import { Component, effect, viewChild, ElementRef, ChangeDetectionStrategy, input } from '@angular/core';
import { Chart } from 'chart.js/auto';
@Component({
selector: 'sales-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<canvas #canvas></canvas>`,
})
export class SalesChart {
data = input.required<number[]>();
private canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private chart: Chart | undefined;
constructor() {
effect(() => {
const points = this.data();
const el = this.canvas().nativeElement;
this.chart?.destroy();
this.chart = new Chart(el, {
type: 'line',
data: { labels: points.map((_, i) => i), datasets: [{ data: points }] },
});
});
}
}
Le code marche. En dev, tout va bien. Puis tu passes en zoneless, tu actives le mode responsive du graphique, et là :
- après un
signal.set(), le graphique se redessine avec un léger décalage visuel ; - Chart.js interroge le DOM (via
getBoundingClientRect()) alors qu'Angular est encore en train de mettre à jour l'arbre pour ce cycle ; - sur une page complexe, tu forces un layout synchrone (le navigateur interrompt son travail pour recalculer positions et tailles) en plein milieu du cycle de change detection, et les FPS chutent.
Le problème n'est pas ton code, c'est effect() qui n'est pas prévu pour ça. Depuis Angular 20, afterRenderEffect() comble ce trou : il combine la réactivité signals d'effect() avec ce que fait bien afterEveryRender() - le DOM que tu lis est celui qu'Angular vient d'écrire pour ce tick (un cycle de change detection complet), pas un état intermédiaire. Le gain n'est pas de supprimer le layout synchrone : lire une dimension force toujours un reflow, avec ou sans afterRenderEffect(). Ce que les phases limitent, c'est le layout thrashing - l'alternance lectures/écritures DOM qui force plusieurs layouts synchrones dans le même tick.
Valide Angular 20+.
afterRenderEffect()est stable (@publicApi) depuis la v20. Angular 19 le proposait, marqué@experimental(l'étape en dessous de developer preview dans le cycle de vie Angular -experimental→developer preview→ stable →deprecated→ retiré - l'API peut encore changer significativement, voire ne jamais se stabiliser). L'API des hooksafterNextRender()etafterEveryRender()(ex-afterRender()) avec leurs phases est couverte en détail dans le guide dédié : on va s'en servir comme point de départ.
Ce que ton effect() fait vraiment quand il touche au DOM
effect() s'exécute pendant un cycle de change detection, pas après. Concrètement :
- Un signal change.
- Angular planifie un cycle de change detection (CD dans la suite).
- Le cycle démarre, les bindings sont ré-évalués, Angular écrit dans le DOM.
- Ton
effect()tourne quelque part dans ce cycle, potentiellement avant que la mise à jour DOM ne soit finie pour ce tick. - Angular termine le cycle.
- La tâche JS se termine, le navigateur reprend la main et fait son style / layout / paint.
Premier problème : ton effect() peut lire un DOM intermédiaire, qui n'est pas encore l'état final de ce tick. Suivant l'ordre d'exécution interne, tu tombes sur des dimensions transitoires, ou sur un viewChild() déjà résolu dont le parent n'a pas encore reçu sa dernière écriture. Et la classe CSS qu'Angular s'apprête à appliquer, elle, n'est pas encore là.
Deuxième problème : lire une dimension via getBoundingClientRect(), offsetHeight ou scrollWidth force un layout synchrone à ce moment-là. Si Angular écrit dans le DOM juste après ton effect(), il invalide ce layout, et le suivant devra tout recalculer. C'est du gaspillage silencieux.
Le troisième problème touche moins effect() lui-même que le bricolage historique construit autour. En zoneless (défaut depuis Angular 21), un setTimeout(0) ou un requestAnimationFrame() ne redéclenche plus automatiquement de cycle de CD, contrairement au mode Zone.js où le patch Zone s'en chargeait. Le filet de sécurité qui masquait un mauvais timing (retenter après un tour de boucle asynchrone) a disparu, et ton bricolage se casse silencieusement.
Le premier réflexe qui déçoit : afterEveryRender()
Angular 16.2 a livré afterRender(), renommé afterEveryRender() quand l'API s'est stabilisée en v20. Il tourne après que le cycle de CD a mis à jour le DOM, donc le DOM que tu lis est celui de ce tick. Bon timing, mais le hook n'écoute rien : il se déclenche à chaque cycle de CD, y compris ceux qui ne modifient rien de ce qui te concerne.
constructor() {
afterEveryRender(() => {
const points = this.data();
// redessine, même quand data() n'a pas changé
this.chart?.destroy();
this.chart = new Chart(this.canvas().nativeElement, { /* ... */ });
});
}
Sur une page où tu tapes dans un input, où un timer tourne, où le router change de query param, afterEveryRender() se déclenche à chaque tour. Chart.js recréé pour rien. Les FPS s'écroulent. Le pattern classique "je stocke la dernière valeur pour ne recréer que si elle a changé" ressemble à ça :
let previous: number[] | undefined;
afterEveryRender(() => {
const points = this.data();
if (points === previous) return;
previous = points;
this.chart?.destroy();
this.chart = new Chart(/* ... */);
});
Tu viens de réimplémenter le suivi des dépendances des signals à la main. C'est exactement ce que afterRenderEffect() fait pour toi.
afterRenderEffect() : le suivi des signals, plus le DOM stabilisé
afterRenderEffect() est un afterEveryRender() qui suit les signals lus dans sa callback. Il tourne :
- après que le cycle de CD Angular a mis à jour le DOM pour ce tick, comme
afterEveryRender(); - après sa première exécution, uniquement quand un signal lu au passage précédent a changé, comme
effect().
Le DOM que tu lis est celui qu'Angular vient d'écrire, pas un état intermédiaire.
Réécriture directe du composant SalesChart :
import { Component, afterRenderEffect, viewChild, ElementRef, ChangeDetectionStrategy, input } from '@angular/core';
import { Chart } from 'chart.js/auto';
@Component({
selector: 'sales-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<canvas #canvas></canvas>`,
})
export class SalesChart {
data = input.required<number[]>();
private canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
constructor() {
afterRenderEffect((onCleanup) => {
const points = this.data();
const el = this.canvas().nativeElement;
const chart = new Chart(el, {
type: 'line',
data: { labels: points.map((_, i) => i), datasets: [{ data: points }] },
});
onCleanup(() => chart.destroy());
});
}
}
Trois choses ont disparu :
- Le
chart?.destroy()manuel avant recréation :onCleanups'en charge, appelé par Angular avant chaque ré-exécution et lors de la destruction du composant, sans que tu aies à t'inscrire toi-même auDestroyRef(détail plus bas). - La garde "est-ce que la valeur a changé" : le suivi de
data()par le hook garantit qu'on ne ré-exécute la callback que sipointsa changé. - Le pari sur le timing : la version à une seule callback (celle qu'on vient d'écrire) tourne dans la phase
mixedReadWrite, après la mise à jour DOM d'Angular.ela bien reçu toutes les écritures d'Angular pour ce tick.
Les phases : transmettre une valeur d'une lecture à une écriture
Cette phase mixedReadWrite où tourne l'exemple précédent est marquée "à éviter si possible" dans la doc : elle mélange lecture et écriture DOM et provoque du layout thrashing si tu en abuses.
La version complète prend un objet avec quatre phases nommées, exécutées dans un ordre garanti :
earlyRead: lire le DOM avant les phaseswritedu même tick. Jamais écrire ici. La doc la présente comme rarement nécessaire ; c'est pourtant elle qui permet de garderwriteen écriture pure, comme dans l'exemple ci-dessous.write: écrire dans le DOM. Jamais lire ici - la doc Angular est catégorique : toute lecture d'une dimension (offsetHeight,scrollHeight,getBoundingClientRect()...) y force un layout, et casse le groupement deswritede tous les composants du tick (détail juste après l'exemple). L'exemple qui suit respecte la règle à la lettre : la valeur à écrire est calculée dansearlyRead, pas danswrite.mixedReadWrite: à éviter, présent pour les cas où on ne peut pas séparer.read: lire le DOM après les écritures (mesure post-write).
Attention à la nomenclature : earlyRead est "early" par rapport à ta phase write, pas par rapport à la mise à jour DOM d'Angular. Toutes les phases s'exécutent après que le CD a poussé l'état des signals dans le DOM. Ce que les phases orchestrent, c'est l'ordre entre tes propres lectures et écritures.
Cas concret : un fil de discussion où on veut dérouler automatiquement vers le bas quand un message arrive, mais uniquement si l'utilisateur était déjà en train de suivre la conversation (pour ne pas voler son scroll s'il lit un message plus haut).
import { Component, afterRenderEffect, viewChild, ElementRef, ChangeDetectionStrategy, input } from '@angular/core';
interface Message {
id: string;
text: string;
}
@Component({
selector: 'chat-thread',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div #scroller class="thread">
@for (m of messages(); track m.id) {
<p>{{ m.text }}</p>
}
</div>
`,
})
export class ChatThread {
messages = input.required<Message[]>();
private scroller = viewChild.required<ElementRef<HTMLDivElement>>('scroller');
constructor() {
afterRenderEffect({
earlyRead: () => {
this.messages();
const el = this.scroller().nativeElement;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const wasFollowing = distanceFromBottom < 200;
return wasFollowing ? el.scrollHeight : null;
},
write: (targetScrollTop) => {
this.messages(); // `write` a sa propre dépendance : sans cet appel, la phase ne se ré-exécuterait que si la valeur retournée par `earlyRead` change
const target = targetScrollTop();
if (target === null) return;
const el = this.scroller().nativeElement;
el.scrollTop = target;
},
});
}
}
Trois points à noter :
earlyReadtourne après qu'Angular a inséré le nouveau message dans la liste :distanceFromBottommesure donc l'état déjà à jour. La phaseearlyReadcalcule aussi tout ce dontwritea besoin, y compris la valeur à écrire (el.scrollHeight) : c'est ce qui permet àwritede rester une écriture pure, sans aucune lecture de dimension. Le seuil de 200 px absorbe la hauteur d'un ou deux messages fraîchement ajoutés : si l'utilisateur était déjà proche du bas juste avant l'ajout, on considère qu'il suivait, et on renvoie la cible de scroll ; sinon,null.- La valeur retournée par
earlyRead(number | null) arrive danswritesous forme deSignal:targetScrollTop: Signal<number | null>. C'est le contrat typé de l'API : chaque phase, sauf la première déclarée, reçoit en premier argument un signal contenant ce qu'a retourné la phase précédente (même si cette valeur estvoid) ;earlyRead, étant ici la première déclarée, ne reçoit queonCleanup. - Chaque phase est un nœud réactif indépendant (un point du graphe réactif d'Angular qui suit ses propres dépendances signal, sans lien direct avec les autres) - ce n'est pas "un seul
effect()" avec un déclencheur unique en amont.writerelitthis.messages()par prudence : dans cet exemple, la cible retournée parearlyReadchange quasiment à chaque message (la hauteur totale grandit), doncwritese ré-exécuterait presque toujours de toute façon - mais rien ne le garantit, et un exemple où cette valeur resterait stable sur plusieurs messages laisseraitwritesans déclencheur sans cette lecture explicite.earlyReadlit elle aussithis.messages(), donc elle se ré-exécute au même rythme quewrite- parce que les deux phases lisent le même signal, pas parce qu'une phase suivrait l'autre.
Le bénéfice à l'échelle de l'arbre de composants : Angular groupe toutes les phases earlyRead de tous les composants du tick, puis toutes les phases write, puis les mixedReadWrite, puis les read. Résultat : les lectures se regroupent en un seul layout forcé pour l'ensemble des earlyRead, un autre pour l'ensemble des read, au lieu d'un layout par composant qui alterne lectures et écritures. Dans ChatThread, write n'a d'ailleurs aucune lecture de dimension à faire - c'est exactement l'objectif de séparer les phases.
À noter : si ta phase write doit désarmer une animation ou déconnecter un ResizeObserver, tu peux nettoyer directement dans la phase (chaque phase reçoit son propre onCleanup), sans revenir à la version à callback unique.
Les six comportements du hook que tu ne devineras pas
- Nettoyage auto, inconditionnel. Le hook s'inscrit toujours auprès du
DestroyRefdu contexte courant. Piège : l'option{ manualCleanup: true }existe dans le typage (AfterRenderOptions) mais n'a aucun effet surafterRenderEffect()(vérifié de la v19 à la v22) - contrairement àafterEveryRender(), qui la respecte. Le composant nettoie de toute façon à sa destruction ; l'AfterRenderRefretourné garde un.destroy()utilisable si tu veux arrêter le hook plus tôt. - Côté navigateur uniquement. Le hook est ignoré au rendu serveur (SSR comme prerendering) et ne tourne qu'à partir du premier cycle de CD côté client. Angular ne garantit pas que les composants soient déjà hydratés (HTML serveur repris en main par le code client) au moment où la callback tourne, hydratation classique ou incrémentale (
@defer (hydrate ...), voir l'article dédié) - prévois une garde si ce que tu lis ou écris en dépend. onCleanup(fn). Chaque callback - celle du hook simple comme celles des phases - reçoitonCleanupen dernier argument : la fonction passée est appelée avant chaque ré-exécution et à la destruction. C'est là que tu détruis Chart.js, que tu déconnectes unResizeObservervia.disconnect(), ou que tu annules unrequestAnimationFrame.- Au moins une fois. Le hook tourne au minimum une fois après sa création, même si aucun signal n'a encore changé : il faut bien une exécution initiale pour poser l'état de départ.
- Pas dans un contexte réactif. L'appel lève
NG0602en mode dev si tu l'appelles depuis uncomputed()ou uneffect()- et passer{ injector }n'y change rien, cette garde s'exécute avant même de regarder l'option. La seule sortie : appelleafterRenderEffect()depuis un contexte non réactif (constructeur, initialisation de champ de classe, ex.private handle = afterRenderEffect(...)). - Contexte d'injection obligatoire, séparément. Comme
afterEveryRender()etafterNextRender(),afterRenderEffect()a aussi besoin d'un contexte d'injection (sinonNG0203) - une contrainte indépendante de la précédente. Si tu n'es plus dans le constructeur mais que tu restes hors de touteffect()/computed(), c'est là que{ injector }ourunInInjectionContext()rend service.
Détail utile : à l'intérieur d'un afterRenderEffect(), tu peux écrire dans un signal, alors que le même set() depuis un computed() lève une erreur (NG0600: Writing to signals is not allowed in a `computed`). La différence est câblée dans le runtime réactif d'Angular : le nœud qui représente le hook autorise l'écriture (consumerAllowSignalWrites: true), celui d'un computed() l'interdit par défaut. Concrètement, tu peux mémoriser une mesure DOM dans un signal exposé au reste de l'app.
Récap actionnable
| Ce que tu fais | Le bon outil |
|---|---|
| Réagir à un signal, écrire dans un autre signal ou appeler un service | effect() |
| Réagir à un signal ET toucher au DOM (mesurer, dessiner, focus, scroll) | afterRenderEffect() |
| Action DOM une seule fois après le premier render | afterNextRender() |
| Travail DOM à chaque cycle de change detection, indépendant des signals | afterEveryRender() (ex-afterRender()) |
| Lecture ET écriture DOM dans le même tick, avec plusieurs mesures qui se coordonnent | afterRenderEffect({ earlyRead, write, mixedReadWrite, read }) avec phases |
Rappel sur cette dernière ligne : chaque phase garde ses propres dépendances signal ; une phase ne se ré-exécute pas seulement parce que la phase précédente l'a fait (cf. la section phases plus haut).
La règle mentale à garder : dès qu'un effect() touche à un nativeElement, à un canvas, à scroll, à focus, à getBoundingClientRect(), ou dès qu'il pilote une lib graphique, c'est un afterRenderEffect(). Pas parce que ton code va planter, mais parce qu'il va provoquer du layout thrashing et te faire lire un DOM pas encore stabilisé pour ce tick. Deux coûts que tu ne verras qu'en profilant.
afterRenderEffect() te donne à la fois la réactivité signals et le bon timing DOM. En Angular 20+, c'est le remplaçant par défaut de tes vieux effect() DOM. Change une fois, tu ne reviens pas.