thatarif
Go back

Angular Change Detection-Old vs New Way

Sep 27, 2025

Angular’s core magic has always been its ability to seamlessly synchronize your application’s data with the user’s screen. For years, this was powered by Zone.js, a system that automatically detected every potential change, making development feel effortless but sometimes at the cost of performance. Today, Angular is embracing precision over magic. With the introduction of Signals and a modern zoneless architecture, the framework is shifting from an automatic, all-seeing approach to a opt-in strategy that gives developers fine-grained control and a significant performance boost. We are going to explore that fundamental evolution, breaking down the journey from the classic change detection of the past to the newer and efficient way.

There always a race of becoming the fastest one in the industry.

What is Change detection and why we need it?

Simply, Change detection is the process a frontend framework uses to figure out if your application’s data (state) has changed, and if so, which parts of the screen (UI) need to be updated to reflect that change.

We need change detection because state changes constantly, and these changes happen invisibly in memory. A change doesn’t automatically update the screen.

Consider what causes state to change:

  • User Events: Clicking a button, typing in a form.
  • Async Operations: Receiving data from an API call (fetch).
  • Timers: A setTimeout or setInterval callback firing.

Without a change detection system, you would have to manually update the DOM every time something changed. Your code would look like this:

// A user clicks a button to fetch data
let userName = 'Guest'

// After API call finishes...
userName = 'Pablo'

// Now, you must MANUALLY update the screen
document.getElementById('user-greeting').innerText = 'Hello, ' + userName

Change detection automates this synchronization. Instead of you manually writing DOM update code, the framework does the heavy lifting. You simply change the state variable (userName = "Pablo"), and the change detection system ensures the UI correctly reflects that new state.

React maintains a virtual dom for analyzing the changes, it uses “hooks” primitives to perform UI re-rendering. While Angular look for changes from top to bottom of the entire component tree. Every frontend library/ has their own mechanism of updating the UI when the state changes.

Let’s understand a little about change detection in Angular and how angular performs reactive behaviors.

Change detection in Angular

Look at example below, we are updating value on click of a button and also we are performing an async task using a setTimeout. Both actions leads to the change detection process to start.

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: `
    <div>
      <h1>{{ value }}</h1>
      <button (click)="updateValue()">change value</button>
      <app-child></app-child>
    </div>
  `,
})
export class ParentComponent {
  value = 0

  constructor() {
    setTimeout(() => {
      console.log('after 10 seconds')
      this.value = Math.floor(Math.random() * 1000)
    }, 10000)
  }

  updateValue() {
    this.value = Math.floor(Math.random() * 1000)
  }
}

Let’s dive deeper -

When updateValue() is called via click event, this is how change detection updates the View:

  1. Change Detection Trigger: Event handler modifies value property. Angular’s zone.js triggers change detection cycle. **Marking the component and its ancestor component as dirty.

Hold on !! WTH is Zone.js

zone.js watching everything
zone.js watching everything

Zone.js is a library that creates a special execution context, called a “zone,” that persists across asynchronous operations. It achieves this by “monkey-patching” most of the browser’s asynchronous APIs. Monkey-patching is a technique where the default behavior of these functions is modified at runtime.

It patches -

  • MicroTasks - Promise
  • MacroTasks - setTimeout, setInterval, setImmediate, requestAnimationFrame, etc.
  • EventTasks - XMLHttpRequest, HTMLElement properties, EventTarget (events like click, change, copy, mouseover etc.)

Mainly changes are triggers for two types of events

  1. Browser events like click
  2. Network calls, Async events, Timers

Now coming back to the above mentioned example component and when updateValue is called.

  1. Angular goes top to down and do the view checking for each dirty components and run the update phase. It goes top to down, that’s why marking of all ancestors node to be dirty is essential, if not, Angular will skip the view checking if nothing has changed at the top level components even if their child component has changes.
  2. Update Phase (breakdown below):
    • Angular executes template function with rf = 2
    • i0.ɵɵadvance(2) navigates to text node at index 2
    • i0.ɵɵtextInterpolate(ctx.value) updates text content:
    • Runs lifecycle hooks

Let’s break down the parent.component.ts implementation and examine what @angular/compiler generates behind the scenes. After compiling the application with ngc, we get the component definition including the template function with render flags (rf) and component context (ctx). The generated code shows how Angular manages DOM nodes and their indexes - for example, the div starts at index 0, h1 at index 1, and the text node at index 2 (which is crucial for targeted updates during change detection).

Breakdown of template functions and internals
Breakdown of template functions and internals

Key points

  • Only the specific text node (index 2) is updated, not the entire component
  • Lifecycle Hooks ngDoCheck() runs before update phase, ngAfterViewChecked() after

NgZone

NgZone is Angular’s dedicated service that acts as an execution context for asynchronous operations. It’s essentially Angular’s wrapper around the Zone.js library.

In Angular components, When a user clicks a element, the event is intercepted by Zone.js’s patched listener. This signals to Angular that an event has occurred within the “Angular zone.”

NgZone has a very important EventEmitter called onMicrotaskEmpty. To understand this, we need a quick concept of the JavaScript Event Loop’s tasks:

  • Macrotasks (or Tasks): These are things like setTimeout, setInterval, I/O, and UI rendering. Crucially, user events like a click are handled as macrotasks.
  • Microtasks: These are smaller tasks that need to run immediately after the current script finishes but before the next macrotask. The most common source of microtasks is a resolved Promise (.then())

Whenever an asynchronous task gets started, it is intercepted by zone.js, and when the task is finished, it emits value from onMicrotaskEmpty.

NgZone change detection scheduler subscribes to onMicrotaskEmpty .

ApplicationRef is a core Angular service that represents the entire application instance. During the application’s startup process, it subscribes to NgZone.onMicrotaskEmpty. When ApplicationRef receives the signal from onMicrotaskEmpty, it executes its tick() method.

ApplicationRef.tick() is the command that triggers a full change detection cycle for the entire application. Angular source code documentation says

/**
 * Invoke this method to explicitly process change detection and its side-effects.
 */
 tick(): void;
// NOTE - source code has been edited for clarity
@Injectable({ providedIn: 'root' })
export class NgZoneChangeDetectionScheduler {
  initialize(): void {
    this.zone.onMicrotaskEmpty.subscribe({
      next: () => {
        this.zone.run(() => {
          this.applicationRef.tick()
        })
      },
    })
  }
}

Opting out of Zone.js change detection in Angular

Before v18 of Angular, If we wanted to opt out zone.js automatic change detection and manually let Angular know about changes we needed to do this on bootstrap code.

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app/app.module'

platformBrowserDynamic()
  .bootstrapModule(AppModule, {
    ngZone: 'noop',
  })
  .catch((err) => console.error(err))

In above case, we would have to tell Angular manually to run the changes using ChangeDetectorRef.detectChanges() or  ChangeDetectorRef.markForCheck() .

Later we will see, what Angular is trying to do with Zoneless Angular.

If summarized , By default this is how the default change detection process works, Angular app going from top to bottom, looking for changes and checking the view of dirty components.

Default change detection
Default change detection

Angular component tree, visualizing default change detection process

We have two main problems with angular change detections

  1. The problem with the default strategy is that it often runs checks even when nothing has changed.
  2. It runs too often.

Now to solve the above problems, Angular has a different way of handling change, we will discuss those in the next section.

OnPush change detection strategy

Angular also provides another strategy for any component to detect changes i.e. OnPush strategy. This is a more stricter way of change detection strategy where Angular looks for certain types of changes to happen before checking the component and its children using this strategy.

We can enable this strategy by providing changeDetection as ChangeDetectionStrategy.OnPush.

@Component({
	 selector: 'app-product-listing',
	 changeDetection: ChangeDetectionStrategy.OnPush,
	 template: `...`
})

Angular will check this component only when following changes has occurred in the component

  • Any event has been fired within the component.
  • Async pipe stream
  • Component inputs has been changed. (Note - Object’s reference is compared, if the reference has been changed, then only it will be considered, if the object is mutated directly, It won’t work.)
  • using ChangeDetectorRef.detectChanges() or  ChangeDetectorRef.markForCheck() Note - detectChanges() differs from markForCheck(). The latter runs change detection asynchronously, while detectChanges() runs synchronously for the specific component and its children. Use markForCheck() for most of the cases.

This not only reduces the change detection process but also makes us write more cleaner components as many developers code loosely knowing that Angular default change detection will handle all the scenarios anyway.

Let’s visualize the new strategy using the diagrams

Scenario 1

onpush scenario 1
onpush scenario 1

Above diagram is the previously demonstrated scenario, where a change happens in component G which is propagated to the top. But in this case, Component A and D are using OnPush. So when angular starts its change detection process, it sees that A is a OnPush component and nothing has changed so it skips the entire subtree of A. But you can see, for component F nothing has changed, but it still gets checked as it doesn’t use OnPush.

Scenario 2

onpush scenario 2
onpush scenario 2

In above scenario, an event is handled by component A which is an OnPush component, in this case Angular will mark the component and its ancestors as dirty and also it’s non OnPush child components will get checked, skipping component D.

Scenario 3

onpush scenario 3
onpush scenario 3

In above case, an event is handled by component D which is a descendent of an OnPush component A. In this case also, the changed component and its ancestors will be marked dirty, and Angular will check the entire component tree from top to bottom in above case.

Scenario 4

onpush scenario 4
onpush scenario 4

In above scenario, An Input has been received from the parent of component A which itself is an OnPush component, In this case when Input changes through template binding, Angular will mark component A as dirty as receiving new Input values is a criteria for any OnPush component to be checked. Also component D will be skipped as its an OnPush and it doesn’t receive any input or has any other criteria either for checking to happen.

Note - When using APIs like @ViewChild or @ContentChild to get reference of child component and manually changing an @Input property through typescript, then Angular will not mark the OnPush component as dirty, we need to use ChangeDetectorRef.markForCheck() in this case.

OnPush strategy forces us to use async pipe rather than manually subscribing and unsubscribing, which leads to more declarative code. Async pipe automatically manages subscriptions and triggers change detection only when new values arrive.

One neat setting to create component using cli with default OnPush change detection strategy is,

// angular.json

"schematics": {
	"@schematics/angular:component": {
			"changeDetection": "OnPush"
	}
}

The change detection processes that we discussed above where zone.js notifies the change detection scheduler to perform detection from the top to bottom, is known as Global mode change detection in newer version of Angular.

Further in the next section, we will study about new mode of change detection, and plans of Angular team to improve the current process of tracking changes.

The “New Way”: Signals & Zoneless Architecture

At the heart of this new architecture are Signals. Think of a signal as more than just a variable; it’s a special wrapper around a value that knows exactly what parts of your application are listening to it. When its value changes, it doesn’t just update silently—it sends out a notification directly to the components that care. This shift from broad checking to precise notification is the key to unlocking a more efficient, fine-grained reactivity and is the foundation of Angular’s modern, zoneless future.

When signal was introduced, Angular kept two types of change detection mechanism, one which is default & OnPush and another is based on signals. For backward compatibility, angular detects both kind of change detection and until v17 it still relied on zone.js to mark ApplicationRef.tick() and perform change detection.

For signal based approach the template of any component acted as an effect/consumer, In other words, templates are a part of Reactive consumers (effect and computed are also a part of it). So whenever any signal changes that is used inside the template, it knows that signals has changed, and thus the component is marked as RefreshView.

The ancestor components of the RefreshView component is marked as HasChildViewsToRefresh . This helps angular to drill down to dirty component and check for changes only where it is needed, further reducing the complexity of change detection process.

The new Targeted mode was introduced after version 16 which only checks views with RefreshView flag. Targeted mode is activated only when the component has OnPush + HasChildViewsToRefresh marks.

/* The change detection traversal algorithm switches between these modes based on various
 * conditions.
 */
export const enum ChangeDetectionMode {
  /**
   * In `Global` mode, `Dirty` and `CheckAlways` views are refreshed as well as views with the
   * `RefreshView` flag.
   */
  Global,
  /**
   * In `Targeted` mode, only views with the `RefreshView` flag are refreshed or views has a changed signal
   */
  Targeted,
}

and this is how detectChangesInView performs checking of view in Global Mode and Targeted Mode.

The View is refreshed if the view is CheckAlways/Dirty and ChangeDetectionMode is Global . The view is also refreshed if it has RefreshView flag.

But in Targeted mode, the view is not refreshed but descendants are traversed when HasChildViewsToRefresh flag is set.

/**
 * Visits a view as part of change detection traversal.
 */
function detectChangesInView(lView: LView, mode: ChangeDetectionMode) {}

Angular switch between both ChangeDetectionMode while traversing the component tree for backward compatibility, as Angular still supports zone.js based change detection.

Local change detection

Now what is this technical jargon? One more concept to learn?

Local change detection is a special scenario that occurs when both the component needing to be checked and all its ancestors use OnPush strategy. For this to work, the change must happen exclusively through a signal update. In this case, the parent components will be bypassed and only the specific component will be checked. If changes occur through other means—such as browser events or async tasks—then the ancestors will still be marked dirty. This is how local change detection works in practice.

Zoneless

Starting from version 16, Angular team shifted the narrative for modern Angular to be become zoneless (removing zone.js from angular) and use fine-grained reactivity primitives like signals to manage change detection.

From version 18, angular provided an experimental provider for enable zoneless and the component can utilize OnPush + signals for writing reactive code. It means, just changing the properties inside the component would not be detected and the view will not be updated. But listeners are detectable by angular and whenever any events are performed inside a component, the component is marked as to be check. Only things which can trigger markForCheck() like observables with async pipe, signal updates, etc. can only cause change detection process when using zoneless. These notification for change is sent to ChangeDetectionScheduler and the scheduler applies the dirtyFlags inside ApplicationRef based on which, tick() is called to start change detection mechanism.

In angular v20, you can configure zoneless by providing this on main config.

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
})

And finally, remove zone.js pollyfill from angular.json.

// AppComponent: Using signals, not relying on zones
@Component({
  selector: 'app-root',
  template: `
    <h1>Counter: {{ counter() }}</h1>
    <button (click)="increment()">Increment</button>
  `
})
export class AppComponent {
  counter = signal(0);

  increment() {
    this.counter.update(c => c + 1);
  }
}

No global async patches, so background timers, fetches, or direct mutations do nothing unless you explicitly call markForCheck() or update a signal.

// Example: Showing what doesn't trigger
ngOnInit() {
  setTimeout(() => {
    this.counter.update(c => c + 100); // Only this call triggers change detection
    // If you just modified a property here and didn’t use a signal, view wouldn’t update
  }, 2000);
}

Why is this better?

  • Predictable and lightweight reactivity.
  • No “magic” dirtying due to async hacks or browser events.
  • Components only re-render when you intend via signals or explicit APIs.
  • Easier mental model for debugging and optimization.

Conclusion

At its core, change detection is about keeping your application’s state and its UI in sync. Zone.js automated this process, watching over everything from clicks to network requests. While convenient, this “check everything” strategy can be inefficient. The new approach, centered around Signals and a zoneless approach, is much more direct. Instead of the framework guessing when to check for changes, Signals allow our components to explicitly state what data they depend on. When that specific data changes, only the necessary parts of the UI are updated.

That’s All. I am really excited about the future of Angular and I believe in the Angular revenge arc.


thatarif