Skip to content

Angular NG0100: Fix ExpressionChangedAfterItHasBeenCheckedError

NG0100 ExpressionChangedAfterItHasBeenCheckedError means a binding changed after Angular's dev-mode verification pass. Here's the exact cause, and the fix.

angular angular-errors ng0100 change-detection zoneless signals angular-material
Bharath G
Reading Progress

On This Page

The Error

In development mode, this is what lands in the console:

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError:
Expression has changed after it was checked. Previous value for
'disabled': 'false'. Current value: 'true'. Expression location:
DashboardCardComponent component

    at throwErrorIfNoChangesMode (core.mjs:8934:15)
    at bindingUpdated (core.mjs:9946:9)
    at ɵɵproperty (core.mjs:11238:5)
    at DashboardCardComponent_Template (dashboard-card.component.html:1:1)
    at executeTemplate (core.mjs:12873:9)
    at refreshView (core.mjs:12688:13)
    at detectChangesInView (core.mjs:12988:9)
    at ApplicationRef.tick (core.mjs:33210:11)

You'll see the same shape from Angular Material components — the actual issue reports are full of variants like Previous value for 'mat-menu-panel-animating': 'true'. Current value: 'false' — and from the router: ExpressionChangedAfterItHasBeenCheckedError fired by RouterOutlet#isActivated flipping mid-navigation. The pattern is always the same three parts: a binding name, a previous/current value pair, and the component (or directive) where Angular caught the mismatch.

Angular prints the error message with a link to https://angular.dev/errors/NG0100, and the docs are explicit about scope: this error is thrown only in development mode. Angular runs change detection twice per tick in dev mode — once to refresh bindings, once more (in checkNoChanges mode) to verify nothing moved — and NG0100 is what that second pass throws when a value disagrees with itself. In a production build (ng build with optimization: true, the default for the production configuration), throwErrorIfNoChangesMode is compiled out along with the rest of the dev-mode assertions. The bug doesn't go away — the two-pass check that was the only thing telling you about it does. You get a screen that's subtly one tick behind, silently, in front of users.

This message text and the double-check mechanism have been stable since View Engine days and haven't changed shape through the Ivy years. What has changed is how often you hit it and why:

  • v17–v20, Zone.js apps: the classic causes — ngAfterViewInit writes, child-to-parent binding, async validators — dominate.
  • v20.2+ zoneless (provideZonelessChangeDetection), and v21+ where zoneless is the default for new apps: NG0100 starts showing up from code that used to work by accident under Zone.js's blanket change-detection sweeps, because the new scheduler only re-renders when something explicitly notifies it (a signal write, an @Input/model() change, an async pipe emission, or markForCheck()).
  • v22, OnPush as the default ChangeDetectionStrategy for new components (with ChangeDetectionStrategy.Eager as the explicit opt-out to the old always-check behavior): components that never declared a strategy now behave like OnPush out of the box, which changes when the second verification pass sees a component at all, and moves some NG0100s to show up (or disappear) compared to pre-v22 code.

How to Reproduce It (step-by-step)

Scaffold a standalone, zoneless v22 app so the repro matches the current default toolchain:

bash

npx @angular/cli@22 new ng0100-repro --standalone --routing=false --style=css --defaults
cd ng0100-repro
npm install

package.json dependency block (pinned to what ng new in 22.1 produces):

json

{
  "dependencies": {
    "@angular/common": "^22.1.0",
    "@angular/compiler": "^22.1.0",
    "@angular/core": "^22.1.0",
    "@angular/platform-browser": "^22.1.0",
    "rxjs": "~7.8.0",
    "tslib": "^2.6.0"
  },
  "devDependencies": {
    "@angular/build": "^22.1.0",
    "@angular/cli": "^22.1.0",
    "typescript": "~5.9.0"
  }
}

Note there's no zone.js dependency — v21+ ng new no longer adds it, and app.config.ts has no provideZonelessChangeDetection() call either, because zoneless is the unwritten default now.

src/app/app.config.ts:

ts

import { ApplicationConfig } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [],
};

src/app/dashboard-card.component.ts — the classic trigger, a value computed and written during ngAfterViewInit:

ts

import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';

@Component({
  selector: 'app-dashboard-card',
  standalone: true,
  template: `
    <div #box class="card" [class.is-tall]="isTall">
      <ng-content />
    </div>
  `,
  styles: `.is-tall { min-height: 240px; }`,
})
export class DashboardCardComponent implements AfterViewInit {
  @ViewChild('box') box!: ElementRef<HTMLDivElement>;
  isTall = false;

  ngAfterViewInit(): void {
    // Reads layout, then writes a binding Angular already checked this tick.
    this.isTall = this.box.nativeElement.scrollHeight > 200;
  }
}

src/app/app.component.ts:

ts

import { Component } from '@angular/core';
import { DashboardCardComponent } from './dashboard-card.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DashboardCardComponent],
  template: `
    <app-dashboard-card>
      <p>Some content that may or may not push past 200px tall.</p>
    </app-dashboard-card>
  `,
})
export class AppComponent {}

Run it:

bash

ng serve

Open the app and watch the console: NG0100 fires on first load because isTall is read by refreshView, then rewritten one line later in ngAfterViewInit, which Angular's checkNoChanges pass catches before the tick ends. Now run:

bash

ng build --configuration production

and serve the dist/ output with any static server. The console is clean — optimization: true strips throwErrorIfNoChangesMode — but isTall is still wrong on the very first paint; it only corrects itself on the next change-detection run, which for an OnPush component (the v22 default) might be "never," until something else notifies it.

Environment-specific triggers to note when reproducing a real bug report: dev-server only (production build hides it, doesn't fix it); worse under OnPush because there's no next Zone.js-driven sweep to paper over the stale frame; specific to the first render or to a route change when the offending write lives in a lifecycle hook that only runs once; and, in zoneless apps, triggerable by code that never used to run inside Angular's zone at all (a ResizeObserver callback, a raw setTimeout, a WebSocket handler) now that nothing forces a blanket re-check.

Version Behaviour Matrix (Angular v17 / v18 / v19 / v20 / v21 / v22)

VersionMessage & mechanismNotes
v17Identical wording; two-pass dev-mode check via Zone.js NgZone.onMicrotaskEmptyStandalone is the ng new default, but NG0100 itself is architecture-agnostic
v18Identical; zoneless experimental (provideExperimentalZonelessChangeDetection)Early adopters see new causes: notifications instead of Zone sweeps
v19Identical; standalone: true is now the implicit defaultNo change to the error itself
v20Identical message; zoneless goes stable in 20.2 (provideZonelessChangeDetection)This is where "it worked in Zone.js, throws now" reports start climbing
v21Identical message; zoneless is the default for new apps (no explicit provider call needed)Most NG0100 reports from this point on are notification-timing bugs, not classic lifecycle-hook bugs
v22 (current, 22.1.x)Identical message; OnPush is the default ChangeDetectionStrategy for new components (Eager is the explicit opt-out)Changes which components get a second look at all — some latent NG0100s surface for the first time, others stop reproducing

NG0100's wording and "dev-mode only" behavior haven't moved across these releases — confirm current wording at angular.dev/errors/NG0100 before citing it. What has moved is the change-detection substrate underneath it (Zone.js → zoneless → OnPush-by-default), which is why an old "just call detectChanges() in ngAfterViewChecked" answer reads very differently against a v22 zoneless app than a v14 one — see Best Practices. Angular Material tracks core's release train, so the mat-menu-panel-animating-style NG0100s in angular/components issues follow the same table.

Why It Happens — Surface Level

Something writes to a value that a template binding already read during this change-detection tick — most often in ngAfterViewInit/ngAfterViewChecked (the value came from measuring the DOM, so it can only be known after the view rendered), in a getter called from a template that returns a different result each call, or in a signal/field write that arrives from outside Angular's normal notification path (a ResizeObserver, a raw timer, a WebSocket message) and lands after the parent was already checked but before the tick fully settles.

Angular isn't wrong to complain: a UI where the same tick renders two different values for the same binding is a real correctness bug, not just a lint warning. The dev-mode assertion is doing its job. Production doesn't remove the bug, it removes your only warning about it.

Why It Happens — Under the Hood

Ivy templates compile to instructions like ɵɵproperty('disabled', ctx.isTall), and ɵɵproperty internally calls bindingUpdated, which stores the last-seen value per binding slot on the component's LView. In development mode, ApplicationRef.tick() doesn't just call refreshView once — after the normal refresh pass across the whole LView tree, it runs a second pass in "no changes" mode (checkNoChangesInternal), re-executing every template's instruction stream and comparing each binding's freshly computed value against what's already stored in that LView slot. bindingUpdated, when called during this second pass, calls throwErrorIfNoChangesMode the moment it sees a mismatch — that's the exact frame you see at the top of the stack trace.

This is also why the error names an "Expression location": Ivy templates instructions are numbered against the compiled template function, and the runtime error formatter maps the failing binding index back to the declaring component so you get DashboardCardComponent component instead of a raw slot number.

Under Zone.js, ApplicationRef.tick() used to run on essentially every browser macro/microtask completion — NgZone.onMicrotaskEmpty fired constantly, so a stray extra tick after your ngAfterViewInit write usually caught up and repainted correctly, which is why so many teams shipped years of code that was subtly NG0100-shaped but never actually threw or looked wrong in production (dev mode still caught it, and got worked around with a setTimeout or a second detectChanges()). The zoneless scheduler (ChangeDetectionScheduler, coalescing via queueMicrotask) is deliberately narrower: it schedules a tick only when it receives an explicit notification — a signal write reaching a consumer, a template event listener firing, an input()/model() change, an async pipe emission, or an explicit markForCheck()/ApplicationRef.tick() call. Code that mutates a plain field from a ResizeObserver callback or a raw addEventListener handler never fires any of those, so under zoneless that write can land inside the same logical tick as the render that should reflect it, hit the exact same "already checked, now it's different" window, and throw NG0100 in a spot that never threw under Zone.js — not because the framework got stricter, but because Zone.js was quietly running extra ticks that happened to cover for it.

The v22 OnPush-as-default change interacts with the same mechanism differently: an OnPush component's subtree is only marked dirty and included in a refreshView pass when one of the recognized triggers fires (an @Input/signal-input change, an event originating inside the subtree, an explicit markForCheck()). A binding write that lands outside those triggers may simply never get checked again after the initial mismatch — no thrown error, just a stale DOM node, which is the "OnPush won't update" failure mode NG0100 is often the first symptom of.

Evidence worth pulling when triaging a real report: reproduce with ng serve (dev mode) to get the thrown error and its stack, not the silent production version; check ng version and npm ls @angular/core to confirm which change-detection regime applies; and, for a component you suspect is OnPush-starved, log render counts from ngDoCheck/an effect() to see whether the subtree is being revisited at all.

How to Fix

Bad pattern — the classic lifecycle-hook write, using a manual force-refresh to silence the error:

  export class DashboardCardComponent implements AfterViewInit {
    @ViewChild('box') box!: ElementRef<HTMLDivElement>;
    isTall = false;

+   constructor(private cdr: ChangeDetectorRef) {}

    ngAfterViewInit(): void {
      this.isTall = this.box.nativeElement.scrollHeight > 200;
+     this.cdr.detectChanges(); // forces the current view synchronously — silences NG0100
    }
  }

This "fix" only silences the dev-mode assertion. It runs a full synchronous change-detection pass on that view (and its children) every time, it doesn't help zoneless apps where the underlying issue is a missing notification rather than a missing tick, and it's exactly the kind of workaround the task list in angular-eslint exists to flag in review.

Real fix — defer the write to the next tick with a signal, so the value is settled before anything downstream reads it:

ts

import { Component, ElementRef, ViewChild, AfterViewInit, signal } from '@angular/core';

@Component({
  selector: 'app-dashboard-card',
  standalone: true,
  template: `
    <div #box class="card" [class.is-tall]="isTall()">
      <ng-content />
    </div>
  `,
  styles: `.is-tall { min-height: 240px; }`,
})
export class DashboardCardComponent implements AfterViewInit {
  @ViewChild('box') box!: ElementRef<HTMLDivElement>;
  isTall = signal(false);

  ngAfterViewInit(): void {
    // Signal write outside the current refresh pass — the scheduler
    // notifies Angular and the *next* tick renders the correct value,
    // instead of contradicting the one that just ran.
    queueMicrotask(() => this.isTall.set(this.box.nativeElement.scrollHeight > 200));
  }
}

Better still, use afterNextRender (or afterRenderEffect for read/write DOM cycles), which exists specifically to run post-render DOM-measuring code outside the check-no-changes window:

ts

import { Component, ElementRef, ViewChild, afterNextRender, signal, inject } from '@angular/core';

@Component({
  selector: 'app-dashboard-card',
  standalone: true,
  template: `
    <div #box class="card" [class.is-tall]="isTall()">
      <ng-content />
    </div>
  `,
  styles: `.is-tall { min-height: 240px; }`,
})
export class DashboardCardComponent {
  @ViewChild('box') box!: ElementRef<HTMLDivElement>;
  isTall = signal(false);

  constructor() {
    afterNextRender(() => {
      this.isTall.set(this.box.nativeElement.scrollHeight > 200);
    });
  }
}

afterNextRender runs after Angular has finished rendering (and after the check-no-changes pass), and a signal write inside it schedules a fresh, separate tick through the normal notification path — no manual detectChanges(), no fighting the scheduler.

For a value that's genuinely derived from other reactive state (not measured from the DOM), skip the lifecycle hook entirely and use computed():

ts

readonly itemCount = signal(0);
readonly isTall = computed(() => this.itemCount() > 6);

When each approach is right: computed() for anything derivable from state you already have; afterNextRender/afterRenderEffect for anything that has to measure the live DOM; a bridged signal write (queueMicrotask, or an RxJS source through toSignal) for third-party callbacks that fire outside Angular's notification path. ChangeDetectorRef.detectChanges() is acceptable only as a last resort in code you don't control (a legacy third-party wrapper) and should carry a comment saying why — never treat it as the default fix.

What each shortcut actually costs, since these show up constantly in the wild: detectChanges() sprinkled after every mutating write silences NG0100 but re-runs full CD synchronously on that subtree, and does nothing for zoneless apps where the missing piece is a notification, not a tick; wrapping the write in setTimeout(() => …) moves it to a macrotask, which does dodge the current check-no-changes pass but adds a real (if tiny) delay and a flash of the wrong value; and disabling the assertion isn't actually possible in dev mode by design — if you find yourself grep-ing for a flag to turn it off, that's a sign the underlying write needs to move, not the check.

Clean-rebuild recipe, useful when NG0100 behavior looks inconsistent between machines (usually a stale .angular/cache compiling against an old signal graph, or a duplicate @angular/core):

bash

rm -rf node_modules package-lock.json
npm cache verify
npm install
rm -rf .angular/cache          # the CLI's build cache, keyed by config + deps
npx ng cache clean
npm ls @angular/core           # prove there is exactly one copy
npx ng version                 # prove CLI, framework, TS and Node line up
npx ng build --configuration production

Practices & Better Design

The fundamentally better design is: templates read signals and computed()s, DOM measurement happens in afterNextRender/afterRenderEffect, and nothing outside those two categories writes a value a template binds to. That ordering makes the two-pass dev check structurally unable to catch you out, because every write either happens before the tick that will render it (constructor, ngOnInit, input()/model() changes) or is deferred, via a real notification, to a tick of its own.

ts

@Component({ /* ... */ })
export class DashboardCardComponent {
  @ViewChild('box') box!: ElementRef<HTMLDivElement>;
  readonly isTall = signal(false);

  constructor() {
    // Runs after render, outside checkNoChanges — the correct place
    // for anything that has to read layout.
    afterNextRender(() => this.isTall.set(this.box.nativeElement.scrollHeight > 200));
  }
}

This generalizes past this one bug: bridge every non-Angular event source (ResizeObserver, IntersectionObserver, WebSocket onmessage) into a signal write or an Observable piped through toSignal(), rather than mutating a plain field and hoping something notices. Prefer computed() over an effect() that copies one signal into another — computed() is pull-based and glitch-free by construction, so it can't produce a value that's "one write behind." And treat OnPush (the v22 default) as the assumption to design around, not an opt-in — write state as signal writes or @Input/model() changes, and the subtree gets checked exactly when it needs to be, no markForCheck() required.

Prevent It in the Long-Term

Keep strict: true and strictTemplates on in tsconfig.json — it won't catch value-instability bugs directly, but it keeps template expressions honest. Add a review checklist item to angular-eslint-backed CI: does anything write to a value a template reads, from a lifecycle hook or an external callback? Run ng build --configuration production in CI, not just ng serve — the dev error's absence doesn't mean the stale-frame bug is gone — paired with a Playwright/Cypress smoke test asserting on rendered DOM state, not just "no console error." Watch npm ls @angular/core in CI for duplicate copies, which can make NG0100 behave inconsistently across environments. And give real review attention to any PR adding ChangeDetectorRef.detectChanges(), a setTimeout(() => …, 0) around a binding write, or an effect() that writes another signal — each is either the right tool used correctly or this bug in disguise.

Learnings

  • NG0100 fires only in Angular's dev-mode second verification pass (checkNoChanges); production builds strip the check, not the underlying stale-frame bug.
  • The classic cause is a DOM-measuring write in ngAfterViewInit/ngAfterViewChecked; the modern cause, on v20.2+ zoneless apps, is any write that reaches a template binding without going through a real notification (signal, input(), async pipe, markForCheck()).
  • afterNextRender/afterRenderEffect plus a signal write is the correct place for post-render DOM reads — it defers the write to its own tick instead of contradicting the one that just ran.
  • ChangeDetectorRef.detectChanges() sprinkled after a mutation silences the symptom without fixing the missing notification, and does nothing for the zoneless case.
  • v22's OnPush-by-default (Eager as the explicit opt-out) changes which subtrees get revisited at all — treat NG0100 as an early warning for the "component won't update" failure mode it's closely related to, not an isolated glitch.
angularangular-errorsng0100change-detectionzonelesssignalsangular-material

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments