~8 min de lecture
afterNextRender() et afterRender() : remplace tes setTimeout(0) et tes ngAfterViewInit qui plantent en zoneless
Tu as déjà écrit ce code. Tu sais, le code qui mesure la hauteur d'un élément, qui pose le focus sur un input, ou qui initialise une librairie tierce qui a besoin du DOM rendu.
ngAfterViewInit() {
const height = this.box.nativeElement.offsetHeight;
console.log(height); // 0. Pourquoi ?
}
Et bien sûr, ça plante. La valeur est à 0, ou pire, indéfinie. Tu réessaies dans un setTimeout(() => ..., 0) et là, miracle, ça marche. Tu commit, tu pushes, tu oublies.
Puis tu actives le zoneless. Et là, ton setTimeout(0) ne déclenche plus aucun cycle de change detection. Ton chart Chart.js ne se redessine pas. Ton focus auto disparaît. Bienvenue dans le monde où les hacks de lifecycle ne pardonnent plus.
Bonne nouvelle : depuis Angular 16, il existe deux hooks faits exactement pour ça. Ils s'appellent afterNextRender() et afterRender(), et la majorité des devs ne les ont jamais utilisés. Voici ce qu'ils font, quand les utiliser, et pourquoi tu devrais arrêter de bricoler avec ngAfterViewInit.
Valide Angular 16+ pour les versions simples, 19+ pour les phases (
earlyRead,write,mixedReadWrite,read).
Le problème : Angular ne te donne pas de hook après le rendu effectif
Le cycle de vie d'un composant Angular se termine par ngAfterViewInit. Le nom suggère "après que la vue est initialisée". On pourrait croire que le DOM est complètement rendu, mesurable, et stable. C'est faux.
ngAfterViewInit est appelé après que le template est compilé et inséré dans le DOM, mais avant que le navigateur ait fait son layout et son paint. C'est une distinction critique :
- Le DOM est l'arbre de nœuds en mémoire. Tu peux le manipuler.
- Le layout est le calcul des positions et tailles. Il n'a pas encore eu lieu.
- Le paint est l'affichage à l'écran. Encore plus tard.
Si tu lis offsetHeight, getBoundingClientRect(), ou scrollWidth dans ngAfterViewInit, tu forces un layout synchrone (un reflow), et selon la complexité de la page, tu peux récupérer des valeurs partielles. Pire, tu pénalises les performances en cassant les optimisations du navigateur.
Le hack historique consiste à reporter le code dans un setTimeout(0) ou un requestAnimationFrame(). Ça fonctionne... tant que tu es en mode Zone.js. Le setTimeout re-déclenche un cycle de CD via le patch Zone.js, et tu te retrouves "par chance" après le layout.
En zoneless (signal-based change detection), ce hack se casse en silence. Et c'est exactement le contexte vers lequel Angular pousse depuis la v19.
afterNextRender() : pour les mesures uniques après le rendu
afterNextRender() exécute une fonction une seule fois, après le prochain rendu effectif du composant. C'est exactement ce qu'il te faut pour une mesure DOM, un focus initial, ou l'initialisation d'une librairie qui a besoin d'un élément monté.
❌ Avant : la danse ngAfterViewInit + setTimeout
import { Component, ElementRef, OnInit, ViewChild, AfterViewInit, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-chart',
template: `<div #container class="chart-container"></div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Chart implements AfterViewInit {
@ViewChild('container') container!: ElementRef<HTMLDivElement>;
ngAfterViewInit() {
// Lecture trop tôt : width peut être 0
setTimeout(() => {
const width = this.container.nativeElement.offsetWidth;
this.renderChart(width);
}, 0);
}
private renderChart(width: number) { /* ... */ }
}
Le setTimeout(0) n'est pas une bonne pratique : il dépend du patch Zone.js, il pollue la macrotask queue, et il rend le code asynchrone là où il n'a pas besoin de l'être.
✅ Après : afterNextRender()
import { Component, ElementRef, viewChild, afterNextRender, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-chart',
template: `<div #container class="chart-container"></div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Chart {
private readonly container = viewChild.required<ElementRef<HTMLDivElement>>('container');
constructor() {
afterNextRender(() => {
const width = this.container().nativeElement.offsetWidth;
this.renderChart(width);
});
}
private renderChart(width: number) { /* ... */ }
}
Pas de setTimeout. Pas d'implémentation de AfterViewInit. La callback est garantie d'être exécutée après le rendu complet, layout inclus, et une seule fois. Le code est synchrone du point de vue d'Angular, et compatible zoneless par construction.
afterRender() : pour réagir à chaque rendu
afterRender() est la version récurrente : la callback est appelée après chaque cycle de rendu du composant. Utilisation typique : synchroniser une visualisation tierce avec l'état Angular, repositionner un tooltip flottant, ou tracker les rendus pour du debug.
Cas concret : un panel d'aide qui doit toujours se positionner sous un élément qui peut bouger.
import { Component, ElementRef, viewChild, afterRender, signal, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-floating-help',
template: `
<button #anchor (click)="open.set(!open())">Aide</button>
@if (open()) {
<div #panel class="help-panel">Contenu</div>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FloatingHelp {
protected readonly open = signal(false);
private readonly anchor = viewChild.required<ElementRef<HTMLButtonElement>>('anchor');
private readonly panel = viewChild<ElementRef<HTMLDivElement>>('panel');
constructor() {
afterRender(() => {
const panel = this.panel();
if (!panel) return;
const rect = this.anchor().nativeElement.getBoundingClientRect();
const el = panel.nativeElement;
el.style.top = `${rect.bottom + 8}px`;
el.style.left = `${rect.left}px`;
});
}
}
Attention : afterRender() tourne à chaque cycle de rendu, y compris ceux que tu n'as pas déclenchés volontairement. Sois économe. Pour des mesures coûteuses ou récurrentes, regarde sérieusement si un ResizeObserver ou un IntersectionObserver ne ferait pas le boulot plus efficacement.
Les phases : éviter le layout thrashing
C'est ici que ça devient sérieux. Lire le DOM (mesure) puis l'écrire (style), puis le relire dans la même tâche, force le navigateur à recalculer le layout entre chaque opération. C'est le fameux layout thrashing, et c'est invisible jusqu'à ce que tes animations rament.
Depuis Angular 17, afterRender() et afterNextRender() acceptent un objet avec quatre phases dédiées :
| Phase | Quand | Pour quoi |
|---|---|---|
earlyRead |
Tout début, avant les writes | Mesurer le DOM avant toute mutation |
write |
Après les reads, avant mixedReadWrite |
Écrire dans le DOM (styles, classes) |
mixedReadWrite |
Phase par défaut | À éviter : autorise lecture et écriture |
read |
Tout à la fin | Mesurer le DOM après les writes |
❌ Lecture/écriture mélangées : layout thrashing garanti
constructor() {
afterRender(() => {
const width = this.boxA().nativeElement.offsetWidth; // read : force layout
this.boxB().nativeElement.style.width = `${width}px`; // write : invalide layout
const height = this.boxB().nativeElement.offsetHeight; // read : re-force layout
this.boxC().nativeElement.style.height = `${height}px`; // write : re-invalide
});
}
À chaque alternance read/write, le navigateur recalcule. Sur une page complexe, c'est plusieurs millisecondes perdues par frame.
✅ Phases séparées : le navigateur fait son travail en un seul layout
import { Component, ElementRef, viewChild, afterNextRender, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-aligned-boxes',
template: `
<div #boxA></div>
<div #boxB></div>
<div #boxC></div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlignedBoxes {
private readonly boxA = viewChild.required<ElementRef<HTMLDivElement>>('boxA');
private readonly boxB = viewChild.required<ElementRef<HTMLDivElement>>('boxB');
private readonly boxC = viewChild.required<ElementRef<HTMLDivElement>>('boxC');
private widthA = 0;
private heightB = 0;
constructor() {
afterNextRender({
earlyRead: () => {
this.widthA = this.boxA().nativeElement.offsetWidth;
},
write: () => {
this.boxB().nativeElement.style.width = `${this.widthA}px`;
},
read: () => {
this.heightB = this.boxB().nativeElement.offsetHeight;
this.boxC().nativeElement.style.setProperty('--height', `${this.heightB}px`);
},
});
}
}
Le navigateur regroupe les opérations : il fait un seul layout après earlyRead, applique tous les write, puis fait un second layout pour la phase read. Sur des composants lourds (graphes, listes virtualisées, dashboards), la différence est mesurable au DevTools.
Règle d'or : dès qu'un callback fait à la fois de la lecture et de l'écriture DOM, sépare-le en phases. C'est une optimisation gratuite.
Et le SSR dans tout ça ?
C'est l'autre raison majeure d'adopter ces hooks. afterRender() et afterNextRender() ne s'exécutent jamais côté serveur. Aucune nécessité de wrapper ton code dans un isPlatformBrowser(). Aucun import de PLATFORM_ID. La séparation est faite par Angular.
❌ Le pattern verbeux historique
import { Component, ElementRef, ViewChild, AfterViewInit, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-stats',
template: `<canvas #canvas></canvas>`,
})
export class Stats implements AfterViewInit {
@ViewChild('canvas') canvas!: ElementRef<HTMLCanvasElement>;
private readonly platformId = inject(PLATFORM_ID);
ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initChart(this.canvas.nativeElement);
}
private initChart(el: HTMLCanvasElement) { /* librairie tierce */ }
}
✅ Avec afterNextRender() : la garde-platform disparaît
import { Component, ElementRef, viewChild, afterNextRender, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-stats',
template: `<canvas #canvas></canvas>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Stats {
private readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
constructor() {
afterNextRender(() => {
this.initChart(this.canvas().nativeElement);
});
}
private initChart(el: HTMLCanvasElement) { /* librairie tierce */ }
}
Le code est plus court et il y a une garantie structurelle : tu ne peux pas appeler une API browser-only côté serveur par accident. Pour les apps SSR, c'est un changement de paradigme : tu arrêtes de polluer ta logique avec des conditions de plateforme.
effect() vs afterRender() : ne pas confondre
C'est la question qui revient à chaque session de revue de code. Les deux APIs sont réactives et reposent sur le scheduler d'Angular. Mais elles couvrent des besoins orthogonaux.
effect() |
afterRender() / afterNextRender() |
|
|---|---|---|
| Déclencheur | Changement d'un signal lu dans la fonction | Cycle de rendu du composant |
| Accès au DOM | Pas garanti, le rendu peut ne pas avoir eu lieu | Garanti, toujours après layout |
| SSR | Tourne côté serveur | Browser uniquement |
| Cas typique | Synchroniser localStorage, logger, dériver | Mesurer, focus, librairies tierces |
| Phases DOM | Non | Oui (earlyRead, write, read) |
Mauvais réflexe fréquent : utiliser effect() pour réagir à un signal en lisant le DOM derrière. Le DOM peut ne pas refléter l'état que tu viens de changer. Angular planifie le rendu, et effect() peut tourner avant. Si tu as besoin d'un état DOM cohérent, c'est afterRender() qu'il te faut.
// ❌ Mauvais : effect() qui dépend du DOM
constructor() {
effect(() => {
this.items(); // dépendance signal
const height = this.list().nativeElement.scrollHeight; // DOM possiblement pas à jour
this.updateScrollIndicator(height);
});
}
// ✅ Bon : afterRender() pour la lecture DOM
constructor() {
afterRender({
read: () => {
const height = this.list().nativeElement.scrollHeight;
this.updateScrollIndicator(height);
},
});
}
Le nettoyage : AfterRenderRef et DestroyRef
Comme effect(), afterRender() retourne une référence avec une méthode .destroy(). Dans un composant, Angular nettoie automatiquement quand le composant est détruit. Dans un service ou un contexte d'injection à durée de vie longue, il faut gérer toi-même.
import { Injectable, afterRender, DestroyRef, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ScrollTracker {
private readonly destroyRef = inject(DestroyRef);
constructor() {
const ref = afterRender(() => {
this.trackScrollPosition();
});
this.destroyRef.onDestroy(() => ref.destroy());
}
private trackScrollPosition() { /* ... */ }
}
Pour un service providedIn: 'root', la destruction n'arrive qu'à la fin de vie de l'app, donc pratiquement jamais. Si ton callback fait du travail à chaque rendu, c'est un poids permanent. Préfère un service à portée limitée (composant, route lazy) quand c'est possible.
Les pièges qui restent
Trois choses à savoir avant de migrer tout ton code.
1. Le contexte d'injection est obligatoire. afterRender() et afterNextRender() doivent être appelés dans un contexte d'injection : typiquement le constructor, ou via un runInInjectionContext(). Sinon tu auras un NG0203: inject() must be called from an injection context.
2. Les callbacks ne lisent pas les signals automatiquement. Contrairement à effect(), lire un signal dans afterRender() n'enregistre pas de dépendance. La callback se déclenche sur chaque rendu, pas sur les changements de signal. Si tu as besoin de cette logique, combine effect() avec afterRender() ou utilise un signal interne.
3. afterNextRender() ne tourne qu'une fois, même si le composant re-rend plus tard. Si tu veux ré-exécuter quelque chose à chaque changement, c'est afterRender() qu'il te faut. Si tu veux ré-exécuter une fois après une condition, déclare un nouvel afterNextRender() plus tard, c'est valide.
Récap actionnable
Voilà la matrice de décision à garder en tête quand tu attaques du code lifecycle :
| Besoin | Hook à utiliser |
|---|---|
| Mesurer le DOM une fois après le montage | afterNextRender({ read: ... }) |
| Initialiser une librairie tierce qui a besoin du DOM | afterNextRender(...) |
| Donner le focus à un input à l'init | afterNextRender(...) |
| Repositionner un overlay à chaque rendu | afterRender({ write: ... }) ou ResizeObserver |
| Lire + écrire dans le DOM dans le même cycle | afterRender() avec phases earlyRead / write / read |
| Réagir à un changement de signal pour faire un side effect non-DOM | effect() |
| Synchroniser avec une API serveur | Pas du tout ces hooks : un service ou httpResource() |
Si tu retiens une seule chose : ngAfterViewInit est un hook de framework, pas un hook de rendu effectif. Le DOM existe mais n'est pas mesuré, et en zoneless ton setTimeout(0) ne te sauvera plus. Les nouveaux hooks comblent ce trou, sont compatibles SSR par construction, et te donnent un contrôle fin sur le layout via les phases.
Le plus dur, c'est de t'en souvenir la prochaine fois que tu écriras un setTimeout(0). Si tu te surprends à le faire, c'est qu'il te faut probablement un afterNextRender() à la place.