📧 Reste informé(e) !

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

S'inscrire gratuitement

~7 min de lecture

NG0100 ExpressionChanged : causes et fixes en Angular

Tu lances ton app en dev, tout marche, et la console te crache un pavé rouge : NG0100: ExpressionChangedAfterItHasBeenCheckedError. Tu passes en prod, l'erreur disparait. Magie ? Non. Le bug est toujours là, Angular a juste arrêté de te prévenir. Voici ce que NG0100 raconte vraiment, et comment la tuer pour de bon, en Zone.js comme en zoneless.

Ce que NG0100 veut dire

En mode développement, Angular fait passer la change detection deux fois par cycle. D'abord la vraie passe qui met à jour le DOM. Puis une passe de vérification, checkNoChanges, qui relit toutes les expressions liées et compare avec ce qu'elle vient d'afficher. Si une valeur a bougé entre les deux, Angular lève NG0100.

En production, cette seconde passe est désactivée. L'erreur n'apparait plus, mais l'incohérence reste : ton UI peut afficher une valeur périmée sans rien dire.

C'est le point que tout le monde rate : NG0100 n'est pas un caprice du dev mode. C'est un garde-fou qui signale qu'une valeur liée à ton template change trop tard, après qu'Angular l'a considérée comme stable.

Décoder le message

Bonne nouvelle : l'erreur te donne déjà le coupable. Le message a toujours cette forme :

NG0100: ExpressionChangedAfterItHasBeenCheckedError.
Previous value: 'pending'. Current value: 'ready'.

Previous value est ce qu'Angular a affiché à la première passe, Current value ce qu'il relit à la passe de contrôle. La propriété qui porte ces deux valeurs est ton binding fautif. Tu n'as donc pas à deviner quelle expression a bougé : il te reste juste à trouver quel bout de code la modifie entre les deux passes. Neuf fois sur dix, c'est un hook de cycle de vie ou un callback asynchrone.

Le cas classique (avec Zone.js)

Historiquement, NG0100 est avant tout un problème de timing de cycle de vie. Le coupable typique : tu modifies une propriété liée dans un hook qui s'exécute après la vérification de la vue, comme ngAfterViewInit.

@Component({
  selector: 'app-status-badge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ label }}</span>`,
})
export class StatusBadge implements AfterViewInit {
  protected label = 'pending';

  ngAfterViewInit(): void {
    // La vue a deja ete verifiee avec 'pending'.
    // On la change ici => la passe de controle lit 'ready' => NG0100.
    this.label = 'ready';
  }
}

Même schéma quand un composant parent lit, pendant son propre rendu, une valeur qu'un enfant a modifiée pendant le sien, ou quand un getter de template renvoie une nouvelle référence à chaque appel (get items() { return [...] }). Dans tous ces cas, la valeur vue à la première passe diffère de celle vue à la seconde.

Le scénario parent-enfant est le plus sournois. Le parent affiche, via @ViewChild ou un binding, une donnée que l'enfant calcule au cours de son propre cycle. Comme Angular vérifie les composants de haut en bas, le parent est contrôlé avant que l'enfant ait fini de produire sa valeur définitive : à la passe suivante, la valeur a déjà changé. C'est typiquement ce qui arrive quand on remonte une mesure ou un état de l'enfant vers le parent dans un hook, au lieu de le faire transiter par un signal partagé.

Ce qui change en zoneless (Angular 21)

Depuis Angular 21, le mode zoneless est le défaut, et c'est là qu'il faut être précis : NG0100 existe toujours, la passe checkNoChanges tourne encore en dev. Mais sa cause typique n'est plus la même.

Sans Zone.js, Angular ne déclenche plus la change detection sur n'importe quel événement asynchrone. Il la déclenche quand un signal lu dans un template change, ou quand un composant est explicitement marqué dirty. Le contrat devient clair : si tu modifies une donnée affichée, tu dois le faire d'une manière qu'Angular observe (écrire un signal, appeler markForCheck).

Du coup, en zoneless, NG0100 pointe le plus souvent vers une valeur qui a changé sans prévenir Angular. Ce n'est plus une question de hook trop tardif, c'est une rupture du contrat de notification.

@Component({
  selector: 'app-cart-total',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ total }}</p>`,
})
export class CartTotal {
  readonly #store = inject(CartStore);
  protected total = 0;

  constructor() {
    // CartStore.onChange / sum() : API illustrative.
    // Mutation d'un champ lie hors de tout flux reactif :
    // Angular n'est pas notifie. En dev zoneless, checkNoChanges
    // voit l'ecart => NG0100. En prod, le total reste fige (stale).
    this.#store.onChange(() => (this.total = this.#store.sum()));
  }
}

La nuance est importante : en zoneless, corriger un NG0100 ne fait pas que faire taire un warning de dev, ça supprime un vrai bug d'affichage qui, sinon, passerait silencieusement en prod. Si tu veux le détail du modèle zoneless, on le déroule dans De Zone.js à Zoneless.

Les fixes, du plus propre au pansement

1. Modéliser la valeur comme dérivée avec un signal

C'est le fix moderne et celui qui règle le problème par construction. Un computed renvoie la même valeur pendant tout un cycle de change detection : la passe de vérification relit donc exactement ce que la première a affiché.

@Component({
  selector: 'app-cart-total',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ total() }}</p>`,
})
export class CartTotal {
  readonly #store = inject(CartStore);

  // Derivee, coherente dans la passe, et notifiee automatiquement.
  protected readonly total = computed(() =>
    this.#store.items().reduce((sum, item) => sum + item.price, 0),
  );
}

Plus de champ muté à la main, plus d'écart entre les deux passes, plus de NG0100. Pour le panorama complet des APIs, garde sous la main la cheatsheet Angular Signals.

2. Calculer plus tôt

Si la valeur n'a aucune raison d'être fixée dans ngAfterViewInit, remonte-la là où elle est connue avant la première vérification : à l'initialisation du composant, ou directement dans la déclaration de l'état.

export class StatusBadge {
  // Connu avant le premier rendu : aucune passe ne verra de changement.
  protected readonly label = computed(() => (this.ready() ? 'ready' : 'pending'));
}

3. afterNextRender pour le DOM

Quand tu dois vraiment lire ou écrire le DOM après le rendu (mesurer une hauteur, brancher une lib tierce), afterNextRender est le bon outil : son callback s'exécute après la passe de vérification du rendu courant. Si tu y écris un signal, tu planifies un nouveau cycle de change detection propre, au lieu de muter une valeur déjà contrôlée comme le faisait ngAfterViewInit.

export class Chart {
  readonly #host = inject<ElementRef<HTMLElement>>(ElementRef);
  protected readonly height = signal(0);

  constructor() {
    afterNextRender(() => this.height.set(this.#host.nativeElement.offsetHeight));
  }
}

4. detectChanges, en dernier recours

Forcer une re-vérification avec ChangeDetectorRef.detectChanges() fait disparaitre l'erreur, mais c'est un pansement : tu lances un cycle de change detection supplémentaire pour masquer un flux mal modélisé. À réserver aux cas où tu intègres du code que tu ne contrôles pas, jamais comme réflexe.

private readonly cdr = inject(ChangeDetectorRef);

ngAfterViewInit(): void {
  this.label = 'ready';
  this.cdr.detectChanges(); // re-synchronise, mais traite le symptome
}

Avant / après

// Before : etat impératif muté trop tard => NG0100 en dev, stale en prod
protected label = 'pending';
ngAfterViewInit(): void {
  this.label = this.resolveLabel();
}

// After : etat dérivé, cohérent par construction
protected readonly label = computed(() => this.resolveLabel());

Le déclic, c'est d'arrêter de voir NG0100 comme une erreur à contourner et de la lire comme ce qu'elle est : le signe qu'une valeur d'affichage est calculée au mauvais moment, ou modifiée sans le dire à Angular.

Pièges fréquents

  • L'erreur vient d'une lib tierce. En zoneless, des composants comme certains d'Angular Material ont mis du temps à être totalement compatibles, et déclenchaient NG0100 sur des bindings internes. Si la stack pointe vers un composant que tu n'as pas écrit, vérifie d'abord que tu es à jour, plutôt que d'enrober ton code de detectChanges.
  • Un effect qui écrit un signal lu dans le template. Modifier, depuis un effect, un signal affiché dans la même vue rejoue une mise à jour après coup. Préfère un computed pour les valeurs dérivées ; garde effect pour les effets de bord (logs, synchronisation externe), pas pour produire de l'état affiché.
  • Le getter de template. {{ buildLabel() }} ou get label() qui recalcule une nouvelle référence à chaque appel finit par diverger entre les deux passes. Mémoïse avec un computed.
  • L'erreur ne tombe qu'en SSR. Le rendu serveur exécute lui aussi la vérification ; un état initialisé différemment côté serveur et côté client peut la réveiller uniquement à l'hydratation.

À retenir

  • NG0100 est dev-only dans les deux modes : la passe checkNoChanges n'existe qu'en développement.
  • Avec Zone.js, c'est surtout un problème de timing (mutation dans un hook tardif, parent qui lit l'enfant).
  • En zoneless (v21), c'est surtout une valeur changée sans notifier Angular : un vrai bug de stale, pas un détail de cycle.
  • Le fix par défaut est le signal dérivé (computed), cohérent dans la passe et notifié tout seul. afterNextRender pour le DOM, detectChanges seulement en dernier recours.

Cette erreur fait partie d'une série qu'on documente : voir aussi NG0201 No provider found et NG0203 inject() hors contexte.


Envie de vraiment comprendre la change detection et la réactivité par signaux plutôt que de subir les NGxxxx ? Forme-toi avec EasyAngularKit.

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