~12 min de lecture

afterNextRender() et afterEveryRender() : remplace tes setTimeout(0) et tes ngAfterViewInit qui plantent en zoneless

TL;DR

ngAfterViewInit tourne pendant le cycle de change detection : le DOM que tu y lis n'est pas forcément celui qu'Angular a fini d'écrire pour ce tick, et le setTimeout(0) qui rattrapait le coup ne redéclenche plus rien en zoneless. afterNextRender() exécute ta callback une seule fois après l'écriture DOM du tick, afterEveryRender() à chaque cycle, et ni l'un ni l'autre ne tourne côté serveur : la garde isPlatformBrowser disparaît. Disponible depuis Angular 16.2 (sous le nom afterRender() jusqu'à la v19 incluse), la forme à phases depuis la 18.1.

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. Tu réessaies dans un setTimeout(() => ..., 0) et là, miracle, ça marche. Tu commites, 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.2, il existe deux hooks faits exactement pour ça. Ils s'appellent afterNextRender() et afterEveryRender(), 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.

Le code de cet article cible Angular 20+, où afterEveryRender() est le nom stable. La fonctionnalité existe depuis Angular 16.2 pour la forme simple (une seule callback, sous son ancien nom afterRender()), et depuis Angular 18.1 pour la forme à phases (earlyRead, write, mixedReadWrite, read). Note de nommage : afterEveryRender() s'appelait afterRender() jusqu'à la v19 incluse, encore en developer preview à l'époque - il n'est jamais passé par une période de dépréciation, il a été retiré net à sa stabilisation en v20, sous son nouveau nom. L'ancienne façon de cibler une seule phase (l'option phase et l'enum AfterRenderPhase) a disparu au même moment.


Le problème : Angular ne te donne pas de hook après ses propres écritures DOM

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é pendant le cycle de change detection (CD dans la suite) qui initialise la vue, à un moment où Angular peut ne pas avoir fini d'écrire dans le DOM pour ce tick - un cycle de CD complet - un peu comme effect(). Exemple reproductible : si tu écris, depuis ton propre ngAfterViewInit, dans un signal que ton propre template affiche, cette écriture force une seconde passe de CD dans le même tick. ngAfterViewInit a déjà lu la valeur avant cette seconde passe, qui écrit seule la valeur finale dans le DOM. Ce n'est pas une histoire de layout ou de paint navigateur : afterNextRender(), qu'on voit juste après, tourne lui aussi avant que le navigateur ne fasse ce layout et ce paint. Ce qu'il garantit, c'est que le DOM que tu lis est celui qu'Angular a fini d'écrire pour ce tick, pas un état intermédiaire du cycle de CD.

Si tu lis offsetHeight, getBoundingClientRect(), ou scrollWidth dans ngAfterViewInit, tu forces de toute façon un layout synchrone (un reflow) au moment de la lecture - ça ne dépend pas du hook, c'est le prix normal de mesurer le DOM. Ce qui change d'un hook à l'autre, c'est la fiabilité du contenu mesuré à cet instant. Le offsetHeight à 0 de l'exemple d'ouverture en est une manifestation classique, le mécanisme ci-dessus étant une cause courante parmi d'autres. La mesure elle-même est bien réelle et le layout a bien eu lieu ; c'est le DOM sous-jacent dont rien ne garantit qu'il soit le DOM final de ce tick.

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 qu'Angular a fini d'écrire dans le DOM pour ce tick.

En zoneless (signal-based change detection, défaut depuis Angular 21), ce hack se casse en silence.


afterNextRender() : pour les mesures uniques après le rendu

afterNextRender() exécute une fonction une seule fois, après le prochain cycle de CD de l'application - typiquement celui qui monte ton 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 (la file où le navigateur range les callbacks différées comme setTimeout, traitée après le code JS en cours), 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 qu'Angular a mis à jour le DOM pour ce tick, et une seule fois. Le code est synchrone du point de vue d'Angular, et compatible zoneless par construction. À noter : le hook tourne à l'intérieur de la même tâche JS que le cycle de CD, donc avant que le navigateur ne reprenne la main pour son style / layout / paint. Lire une dimension DOM y force encore un layout synchrone, exactement comme dans un effect(). Le gain est ailleurs : tu lis un DOM stabilisé pour ce tick.


afterEveryRender() : pour réagir à chaque rendu

afterEveryRender() est la version récurrente : la callback est appelée après chaque cycle de CD de l'application, pas seulement ceux déclenchés par ce 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, afterEveryRender, 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() {
    afterEveryRender(() => {
      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 : afterEveryRender() 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. Cette callback unique lit puis écrit dans le même passage - la section phases plus bas montre le principe de séparation sur un autre exemple, et pourquoi ça ne paie vraiment qu'à partir de plusieurs composants sur la même page.


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.

La forme à phases existe depuis Angular 18.1 (à l'époque, afterEveryRender() s'appelait encore afterRender()) : afterEveryRender() 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 earlyRead, 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

Règle stricte pour les trois phases spécialisées : jamais écrire dans earlyRead, jamais lire dans write, jamais écrire dans read. Seule mixedReadWrite autorise les deux - au prix du regroupement des lectures et des écritures entre composants, expliqué juste après l'exemple qui suit.

❌ Une callback qui alterne lecture et écriture

constructor() {
  afterEveryRender(() => {
    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.

✅ Des phases qui se groupent avec celles des autres composants

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;

  constructor() {
    afterNextRender({
      earlyRead: () => {
        this.widthA = this.boxA().nativeElement.offsetWidth;
      },
      write: () => {
        this.boxB().nativeElement.style.width = `${this.widthA}px`;
      },
      mixedReadWrite: () => {
        // lecture qui dépend de l'écriture qui vient de se produire, suivie d'une nouvelle écriture :
        // exactement le cas que cette phase existe pour couvrir, cf. tableau plus haut
        const heightB = this.boxB().nativeElement.offsetHeight;
        this.boxC().nativeElement.style.setProperty('--height', `${heightB}px`);
      },
    });
  }
}

Le navigateur regroupe les opérations : un seul layout pendant earlyRead, tous les write appliqués, puis un second layout quand mixedReadWrite lit boxB (juste après l'avoir écrit dans write). Deux layouts forcés, comme dans la version ❌ ci-dessus - à l'échelle d'un seul AlignedBoxes isolé, ça ne change rien. Le vrai bénéfice apparaît à l'échelle de l'arbre de composants : Angular regroupe les earlyRead de tous les composants du tick, puis tous les write, puis tous les mixedReadWrite, au lieu que chaque composant force ses propres layouts dans l'ordre où son code les déclenche. Sur une page avec beaucoup de composants qui font ce genre de mesure, la différence est mesurable au DevTools.

Règle d'or : dès qu'une callback alterne lecture et écriture DOM, découpe en earlyRead / write tout ce qui peut l'être. Ce qui reste - une lecture qui dépend d'une écriture faite dans le même tick, comme la mesure de boxB ci-dessus - va légitimement dans mixedReadWrite. Le gain grandit avec le nombre de composants qui font pareil sur la même page.


Et le SSR dans tout ça ?

C'est l'autre raison majeure d'adopter ces hooks. afterEveryRender() et afterNextRender() ne s'exécutent jamais côté serveur. Aucune nécessité de wrapper ton code dans un if (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, ChangeDetectionStrategy } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-stats',
  template: `<canvas #canvas></canvas>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
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 de plateforme 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 afterEveryRender() : ne pas confondre

C'est la question qui revient à chaque session de revue de code. Les deux APIs reposent sur le scheduler d'Angular, et c'est à peu près tout ce qu'elles ont en commun : effect() suit les signals que tu lis, afterEveryRender() non - il tourne à chaque cycle, qu'un signal ait changé ou pas.

effect() afterEveryRender() / afterNextRender()
Déclencheur Changement d'un signal lu dans la fonction Cycle de CD de l'application
Accès au DOM Pas garanti, le rendu peut ne pas avoir eu lieu DOM stabilisé pour ce tick (mais avant le layout navigateur)
SSR Tourne côté serveur Browser uniquement
Cas typique Synchroniser localStorage, logger, dériver Mesurer, focus, librairies tierces
Phases DOM Non Oui (earlyRead, write, mixedReadWrite, read)

Mauvais réflexe fréquent : utiliser effect() pour réagir à un signal puis lire le DOM dans la foulée. Le DOM peut ne pas refléter l'état que tu viens de changer. Si la lecture DOM doit se faire à chaque rendu, indépendamment de tout signal, afterEveryRender() est le bon outil - attention, il ne suit aucun signal pour autant : il tourne à chaque cycle de CD, que tes données aient changé ou non (voir le contre-exemple ci-dessous, qui perd volontairement le suivi de items()). Si tu as besoin de suivre un signal ET de lire un DOM à jour, c'est afterRenderEffect() qu'il te faut (piste développée en fin d'article).

// ❌ 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.scrollIndicatorHeight.set(height); // écrit dans un signal, pas dans le DOM
  });
}

// ✅ Bon : afterEveryRender() pour une lecture DOM fiable, mais qui ne suit plus items()
constructor() {
  afterEveryRender({
    read: () => {
      // lecture pure : on capture la mesure dans un signal, on n'écrit rien dans le DOM ici
      const height = this.list().nativeElement.scrollHeight;
      this.scrollIndicatorHeight.set(height);
    },
  });
}

Le nettoyage : AfterRenderRef et DestroyRef

Comme effect(), afterEveryRender() retourne un AfterRenderRef, une référence avec une méthode .destroy(). Le hook s'inscrit tout seul auprès du DestroyRef du contexte courant, service compris - pas besoin de le brancher toi-même sur inject(DestroyRef) (tu peux désactiver ce comportement avec { manualCleanup: true } si tu veux gérer .destroy() entièrement toi-même). Ce qui change dans un service, ce n'est pas l'absence de nettoyage automatique par défaut, c'est la durée de vie du service (nuance illustrée juste après).

import { Injectable, afterEveryRender } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ScrollTracker {
  private readonly ref = afterEveryRender(() => {
    this.trackScrollPosition();
  });

  stop() {
    this.ref.destroy(); // arrêt anticipé et explicite, avant la fin de vie du service
  }

  private trackScrollPosition() { /* ... */ }
}

Pour un service providedIn: 'root', la destruction du service (et donc le nettoyage automatique) n'arrive qu'à la fin de vie de l'app, donc pratiquement jamais. Si ta callback fait du travail à chaque rendu, c'est un poids permanent : c'est pour ce cas-là que .destroy() sert à quelque chose, en arrêt manuel comme ci-dessus. 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. afterEveryRender() et afterNextRender() doivent être appelés dans un contexte d'injection : typiquement le constructor, ou via un runInInjectionContext(). Sinon tu auras un NG0203: afterEveryRender() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext` (message tronqué ici, Angular ajoute un lien vers sa page d'erreur à la suite).

2. Les callbacks ne lisent pas les signals automatiquement. Contrairement à effect(), lire un signal dans afterEveryRender() 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 afterEveryRender() ou utilise un signal interne.

3. afterNextRender() ne tourne qu'une fois, même si le composant refait un rendu plus tard. Si tu veux ré-exécuter quelque chose à chaque changement, c'est afterEveryRender() 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 - mais toujours depuis un contexte d'injection (piège n°1 : passe { injector } ou utilise runInInjectionContext() si tu n'es plus dans le constructeur).

Depuis Angular 20, il existe une réponse directe au piège n°2 : afterRenderEffect() combine la réactivité signals d'effect() avec le bon timing DOM de ces hooks, sans bricolage manuel. Guide dédié : afterRenderEffect() : le hook Angular 20 qui remplace tes effect() qui touchent au DOM.


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 (lecture de position + écriture) afterEveryRender({ earlyRead, write }) - une seule callback suffit pour un prototype - ou ResizeObserver
Lire + écrire dans le DOM dans le même cycle sans pouvoir séparer proprement afterEveryRender() avec mixedReadWrite
Réagir à un changement de signal pour faire un side effect non-DOM effect()
Réagir à un changement de signal ET toucher au DOM afterRenderEffect(), guide dédié
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 fin d'écriture DOM. Le DOM existe et tu peux le mesurer, mais rien ne garantit qu'il soit complet pour ce tick, 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.