~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 - experimentaldeveloper preview → stable → deprecated → retiré - l'API peut encore changer significativement, voire ne jamais se stabiliser). L'API des hooks afterNextRender() et afterEveryRender() (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 :

  1. Un signal change.
  2. Angular planifie un cycle de change detection (CD dans la suite).
  3. Le cycle démarre, les bindings sont ré-évalués, Angular écrit dans le DOM.
  4. Ton effect() tourne quelque part dans ce cycle, potentiellement avant que la mise à jour DOM ne soit finie pour ce tick.
  5. Angular termine le cycle.
  6. 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 :

  1. Le chart?.destroy() manuel avant recréation : onCleanup s'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 au DestroyRef (détail plus bas).
  2. 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 si points a changé.
  3. 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. el a 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 :

  1. earlyRead : lire le DOM avant les phases write du même tick. Jamais écrire ici. La doc la présente comme rarement nécessaire ; c'est pourtant elle qui permet de garder write en écriture pure, comme dans l'exemple ci-dessous.
  2. 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 des write de 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 dans earlyRead, pas dans write.
  3. mixedReadWrite : à éviter, présent pour les cas où on ne peut pas séparer.
  4. 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 :

  • earlyRead tourne après qu'Angular a inséré le nouveau message dans la liste : distanceFromBottom mesure donc l'état déjà à jour. La phase earlyRead calcule aussi tout ce dont write a besoin, y compris la valeur à écrire (el.scrollHeight) : c'est ce qui permet à write de 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 dans write sous forme de Signal : 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 est void) ; earlyRead, étant ici la première déclarée, ne reçoit que onCleanup.
  • 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. write relit this.messages() par prudence : dans cet exemple, la cible retournée par earlyRead change quasiment à chaque message (la hauteur totale grandit), donc write se 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 laisserait write sans déclencheur sans cette lecture explicite. earlyRead lit elle aussi this.messages(), donc elle se ré-exécute au même rythme que write - 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 DestroyRef du contexte courant. Piège : l'option { manualCleanup: true } existe dans le typage (AfterRenderOptions) mais n'a aucun effet sur afterRenderEffect() (vérifié de la v19 à la v22) - contrairement à afterEveryRender(), qui la respecte. Le composant nettoie de toute façon à sa destruction ; l'AfterRenderRef retourné 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çoit onCleanup en 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 un ResizeObserver via .disconnect(), ou que tu annules un requestAnimationFrame.
  • 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 NG0602 en mode dev si tu l'appelles depuis un computed() ou un effect() - et passer { injector } n'y change rien, cette garde s'exécute avant même de regarder l'option. La seule sortie : appelle afterRenderEffect() 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() et afterNextRender(), afterRenderEffect() a aussi besoin d'un contexte d'injection (sinon NG0203) - une contrainte indépendante de la précédente. Si tu n'es plus dans le constructeur mais que tu restes hors de tout effect()/computed(), c'est là que { injector } ou runInInjectionContext() 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.

📧 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.