Skip to content

Fix: Angular Change Detection Not Working — View Not Updating

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 every asynchronous browser API — setTimeout, Promise.then, addEventListener, XMLHttpRequest — so Angular knows when to check the entire component tree for dirty data. This patching approach is powerful but brittle. Anything that bypasses Zone.js, or breaks the expected data-flow contract, results in a view that silently ignores your changes.

The OnPush strategy narrows the detection trigger to four events: an @Input() reference change, an event binding inside the component’s template, an Observable emitting through the async pipe, or an explicit call to markForCheck() / detectChanges(). Mutating an existing object or array does not change its reference, so OnPush components skip the check entirely. This is the single most common cause of “my view isn’t updating” in Angular codebases.

A subtler issue is code running outside Angular’s zone. Third-party libraries that register their own event listeners (WebSocket clients, charting libraries, drag-and-drop engines) often execute callbacks outside Zone.js. The data updates correctly in memory, but Angular never learns about it because no zone turn was triggered. Similarly, runOutsideAngular is sometimes used for performance-critical loops and then the re-entry into the zone is forgotten, leaving the template frozen.

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

How Other Frameworks Handle Reactivity

Angular’s zone-based change detection is unique among modern frameworks. Understanding how other tools solve the same problem helps you choose the right pattern — and explains why certain Angular-specific mistakes don’t exist elsewhere.

React uses a virtual DOM diffing model. When you call setState or a useState setter, React schedules a re-render for that component and its subtree. React never patches browser APIs — re-renders are always explicit. The equivalent of Angular’s OnPush is React.memo, which does a shallow comparison of props. Forgetting to create a new object reference causes the same stale-view bug, but React gives you no automatic detection at all — every update is opt-in through state setters.

Vue uses a reactive proxy system. When you declare ref() or reactive(), Vue wraps the value in a Proxy that intercepts property access and mutation. Mutating reactive({ items: [] }).items.push('x') triggers an update automatically because the Proxy traps the .push() call. This means Vue does not have the “mutating an array breaks reactivity” problem that Angular OnPush and React have. The tradeoff is a runtime cost for the Proxy layer and occasional gotchas when destructuring reactive objects.

Svelte takes a compile-time approach. The Svelte compiler rewrites assignments (count = count + 1) into invalidation calls at build time. There is no runtime change detection system, no zone patching, and no virtual DOM. The trade-off: only top-level assignments trigger updates — calling .push() on an array without re-assignment does not, which mirrors the Angular OnPush behavior for a completely different reason.

Solid uses fine-grained signals that are conceptually closest to Angular Signals (16+). A createSignal() returns a getter and setter. Reading the signal inside a reactive context (like JSX) subscribes that context to the signal automatically. No component re-renders happen — only the specific DOM node bound to the signal updates. Angular’s signal() API was directly influenced by Solid’s model, and Angular 16+ Signals aim to replace Zone.js with this fine-grained tracking.

Angular Signals (16+) represent Angular’s shift away from Zone.js. Unlike zone-based detection, signals track dependencies at the individual-value level. Calling count.update(n => n + 1) automatically marks every template expression that reads count() for checking, without touching Zone.js or requiring markForCheck(). If you’re starting a new Angular project or migrating, signals eliminate the entire class of zone-related bugs described in this article.

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)),
);

Check whether your input comes through a pipe or projection. If a parent component passes data through ng-content or a structural directive, the reference may not change from the child’s perspective even though the content did. Wrap projected content in its own component with OnPush disabled, or use signals to track the projected values explicitly.

Profile the change detection tree. If your application has hundreds of components, the default change detection strategy checks every component on every zone turn. Use the Angular DevTools profiler to identify components that run change detection unnecessarily and convert them to OnPush or signals incrementally.

Beware of async pipe inside *ngIf. If the *ngIf evaluates before the Observable emits, the subscription is delayed. Use *ngIf="obs$ | async as data" so the template waits for the first emission, or provide an initial value with startWith() in the pipe chain.

For related Angular issues, see Fix: Angular ExpressionChangedAfterItHasBeenCheckedError, Fix: Angular Signals Not Updating, Fix: Angular RxJS Memory Leak, and Fix: React.memo 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