Skip to content

Fix: Angular Change Detection Not Working — View Not Updating

FixDevs ·

Quick Answer

How to fix Angular change detection issues — OnPush strategy not triggering, async pipe, markForCheck vs detectChanges, zone.js and zoneless patterns, and manual change detection triggers.

The Problem

An Angular component’s view doesn’t update even though the underlying data has changed:

// The data changes but the template never reflects it
this.users = newUsers;     // ← Updated in code
// Template still shows old users list

Or a component using OnPush change detection never re-renders:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ user.name }}`
})

Or an Observable value updates but the template doesn’t reflect the new value:

this.dataSubject.next(newData);
// Template showing {{ data$ | async }} still shows old value

Or an error appears in logs:

ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.
Previous value: 'false'. Current value: 'true'.

Why This Happens

Angular’s change detection is zone-based by default — Zone.js patches asynchronous APIs (setTimeout, Promise, XHR) so Angular knows when to run change detection. When something bypasses Zone.js or breaks the expected data flow, the view doesn’t update:

  • OnPush change detection + mutating objectsOnPush only re-renders when input references change. If you mutate an existing array or object (.push(), direct property assignment), Angular sees the same reference and skips re-rendering.
  • Changes made outside Angular zone — code running in a third-party library, Web Worker, or native browser event that isn’t intercepted by Zone.js won’t trigger change detection automatically.
  • ChangeDetectorRef detached — calling detach() on the ChangeDetectorRef stops all change detection for that component subtree until manually re-attached or detectChanges() is called.
  • ExpressionChangedAfterItHasBeenCheckedError — Angular runs change detection and then verifies the results haven’t changed (in dev mode). If a lifecycle hook (ngAfterViewInit, ngAfterContentInit) modifies data that the template already rendered, this error fires.
  • Observable not subscribed — an Observable is set up but the template uses it directly (without async pipe or manual subscription), so values never reach the view.

Fix 1: Use Immutable Updates with OnPush

ChangeDetectionStrategy.OnPush is more efficient but requires immutable data patterns. Replace the reference instead of mutating it:

// WRONG — mutating the existing array, OnPush won't detect the change
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul>
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
  `
})
export class UserListComponent {
  @Input() users: User[] = [];

  addUser(user: User) {
    this.users.push(user);   // ← Mutates array — OnPush sees same reference
    // View doesn't update
  }
}
// CORRECT — replace the array reference, OnPush detects the change
export class UserListComponent {
  @Input() users: User[] = [];

  addUser(user: User) {
    this.users = [...this.users, user];   // New array reference → OnPush triggers
    // View updates correctly
  }
}

Same applies to objects:

// WRONG — mutating the object property
this.config.theme = 'dark';        // Same reference, OnPush ignores it

// CORRECT — create a new object
this.config = { ...this.config, theme: 'dark' };   // New reference → triggers update

When receiving data from a service, return new references:

// service.ts
updateUser(id: number, changes: Partial<User>) {
  // WRONG — mutating in place
  const user = this.users.find(u => u.id === id);
  Object.assign(user, changes);

  // CORRECT — return new objects
  this.users = this.users.map(u =>
    u.id === id ? { ...u, ...changes } : u
  );
  this.users$.next(this.users);
}

Fix 2: Use Observables with async Pipe

The async pipe subscribes to an Observable or Promise, automatically updates the view when new values arrive, and unsubscribes on component destroy:

// users.service.ts
@Injectable({ providedIn: 'root' })
export class UsersService {
  private usersSubject = new BehaviorSubject<User[]>([]);
  users$ = this.usersSubject.asObservable();

  loadUsers() {
    this.http.get<User[]>('/api/users').subscribe(users => {
      this.usersSubject.next(users);
    });
  }
}
// users.component.ts
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul>
      <li *ngFor="let user of users$ | async">{{ user.name }}</li>
    </ul>
    <p *ngIf="loading$ | async">Loading...</p>
  `
})
export class UsersComponent implements OnInit {
  users$ = this.usersService.users$;
  loading$ = this.usersService.loading$;

  constructor(private usersService: UsersService) {}

  ngOnInit() {
    this.usersService.loadUsers();
  }
}

The async pipe works with OnPush because it calls markForCheck() internally when a new value arrives.

Multiple subscriptions — use combineLatest or withLatestFrom:

// Combine multiple streams
vm$ = combineLatest({
  users: this.usersService.users$,
  loading: this.usersService.loading$,
  error: this.usersService.error$,
});
<!-- Single async subscription with a view model -->
<ng-container *ngIf="vm$ | async as vm">
  <p *ngIf="vm.loading">Loading...</p>
  <ul>
    <li *ngFor="let user of vm.users">{{ user.name }}</li>
  </ul>
</ng-container>

Fix 3: Use markForCheck and detectChanges Correctly

When OnPush is used and you need to trigger change detection manually:

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

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ data }}`
})
export class MyComponent {
  data: string = '';

  constructor(private cdr: ChangeDetectorRef) {}

  // Called from outside Angular zone (e.g., WebSocket, third-party callback)
  updateFromExternalSource(newData: string) {
    this.data = newData;
    this.cdr.markForCheck();   // Schedule check on next CD cycle
    // OR
    // this.cdr.detectChanges();  // Run CD synchronously right now
  }
}

markForCheck() vs detectChanges():

MethodWhen to use
markForCheck()Outside Angular zone changes — schedules the component for checking on the next CD cycle
detectChanges()Need the view to update immediately (synchronously) — use when markForCheck() is too late
detach()Completely pause CD for this component — call detectChanges() manually
reattach()Re-enable automatic CD after detach()

For code running outside NgZone:

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

@Component({ ... })
export class WebSocketComponent {
  constructor(private ngZone: NgZone, private cdr: ChangeDetectorRef) {}

  ngOnInit() {
    // Third-party library callback runs outside Angular zone
    someExternalLibrary.onUpdate((data) => {
      // Run back inside Angular zone to trigger CD
      this.ngZone.run(() => {
        this.data = data;
        // ngZone.run() triggers CD automatically
      });

      // OR use markForCheck if you don't need the full zone re-entry
      this.data = data;
      this.cdr.markForCheck();
    });
  }
}

Fix 4: Fix ExpressionChangedAfterItHasBeenCheckedError

This error appears in development mode when a lifecycle hook changes a value that Angular already checked:

// WRONG — modifying template-bound data in ngAfterViewInit
@Component({
  template: `<p>{{ loading }}</p>`
})
export class MyComponent implements AfterViewInit {
  loading = false;

  ngAfterViewInit() {
    this.loading = true;   // ← Error: value changed after check
  }
}

Fix option 1 — use setTimeout(0) to defer the update:

ngAfterViewInit() {
  setTimeout(() => {
    this.loading = true;   // Deferred to next tick — check cycle is complete
  });
}

Fix option 2 — use ChangeDetectorRef.detectChanges() after the update:

constructor(private cdr: ChangeDetectorRef) {}

ngAfterViewInit() {
  this.loading = true;
  this.cdr.detectChanges();   // Re-run CD immediately after the change
}

Fix option 3 — move the logic to ngOnInit instead:

// ngOnInit runs before the first check — no error
ngOnInit() {
  this.loading = true;   // Safe here
}

Note: ExpressionChangedAfterItHasBeenCheckedError only appears in development mode. It’s Angular telling you the data flow is incorrect — fix the root cause rather than suppressing it. In production, the error is silent but the view may show inconsistent data.

Fix 5: Run Code Inside NgZone

Code that runs outside Angular’s zone (third-party libraries, native events, Web Workers) doesn’t trigger change detection automatically:

@Component({ template: `{{ counter }}` })
export class TimerComponent implements OnInit {
  counter = 0;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    // WRONG — setInterval patched by Zone.js should work, but
    // some environments or native APIs may not be patched
    this.ngZone.runOutsideAngular(() => {
      // Code here intentionally skips CD (good for performance-critical loops)
      setInterval(() => {
        this.counter++;
        // Template won't update — running outside zone
      }, 1000);
    });
  }
}
ngOnInit() {
  this.ngZone.runOutsideAngular(() => {
    setInterval(() => {
      this.counter++;
      // Re-enter zone to trigger CD
      this.ngZone.run(() => {
        // this.counter is already updated, ngZone.run triggers CD
      });
      // OR
      this.cdr.markForCheck();  // Cheaper than ngZone.run for OnPush
    }, 1000);
  });
}

Use runOutsideAngular intentionally for performance-critical code that shouldn’t trigger CD on every iteration (animations, canvas rendering, high-frequency events):

ngOnInit() {
  // Mouse move events fire constantly — run outside zone to avoid CD on every event
  this.ngZone.runOutsideAngular(() => {
    document.addEventListener('mousemove', (e) => {
      this.mouseX = e.clientX;
      this.mouseY = e.clientY;
      // Only update view at 60fps, not on every mousemove
    });
  });

  // Update view on animation frame
  const updateView = () => {
    this.cdr.markForCheck();
    requestAnimationFrame(updateView);
  };
  requestAnimationFrame(updateView);
}

Fix 6: Signals (Angular 16+)

Angular Signals are a reactive primitive that work without zone.js and automatically notify the view of changes:

import { signal, computed, effect } from '@angular/core';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">+</button>
  `
})
export class CounterComponent {
  count = signal(0);                          // Writable signal
  double = computed(() => this.count() * 2);  // Derived signal

  increment() {
    this.count.update(n => n + 1);   // Automatically marks view for check
    // No markForCheck() needed — signals do this automatically
  }
}

Convert an Observable to a signal with toSignal:

import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  template: `
    <ul>
      <li *ngFor="let user of users()">{{ user.name }}</li>
    </ul>
  `
})
export class UsersComponent {
  users = toSignal(this.usersService.users$, { initialValue: [] });

  constructor(private usersService: UsersService) {}
  // No manual subscription, no unsubscribe, no async pipe needed
}

Use signals for shared state between components:

// state.service.ts
@Injectable({ providedIn: 'root' })
export class StateService {
  private _count = signal(0);
  count = this._count.asReadonly();   // Expose read-only signal

  increment() {
    this._count.update(n => n + 1);
  }
}

// component.ts
@Component({
  template: `{{ stateService.count() }}`
})
export class MyComponent {
  constructor(public stateService: StateService) {}
  // Template automatically updates when signal changes
}

Still Not Working?

Enable Angular DevTools (Chrome extension) to visualize the component tree and which components are being checked. The change detection visualizer shows exactly which components Angular is marking dirty and checking.

Check for ChangeDetectorRef.detach() in the component or its parents. A detached CD tree won’t update automatically:

// Find in your code
this.cdr.detach();  // If this is called, use detectChanges() manually

Verify Zone.js is loaded — if it’s missing from polyfills.ts or excluded, Angular falls back to manual CD:

// polyfills.ts
import 'zone.js';  // Must be present unless using zoneless Angular

For Angular 18+ zoneless (experimental) — if you’ve opted into zoneless, you must explicitly use signals or markForCheck() for every update:

// angular.json or bootstrapApplication
bootstrapApplication(AppComponent, {
  providers: [provideExperimentalZonelessChangeDetection()],
});

// In zoneless mode, ALL updates require signals or manual CD trigger
// Zone.js is no longer patching async APIs

Use tap() with debug logging to verify your Observable is emitting:

this.users$ = this.usersService.users$.pipe(
  tap(users => console.log('users$ emitted:', users)),
);

For related Angular issues, see Fix: Angular HttpClient Error Handling and Fix: Angular Router Navigation Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles