How Angular's Change Detection Works

Change detection is fundamental to how Angular maintains the connection between application state and the rendered UI. It's the mechanism that ensures the interface reflects the current data. If this system didn't exist, any data mutation would not be automatically reflected in the view, leaving the app disjointed and buggy. This process is critical for maintaining a representation that is always current and accurate, guaranteeing that events, user interactions, and data fetching are all displayed correctly, resulting in a fluid and intuitive application.


The Inner Workings of Change Detection in Angular

Angular's change detection is a two-phase process:

  1. Flagging the Component for Review: The first step happens when a potential state-altering event occurs. For example, a user interaction like a click on a button. Angular flags the affected component as dirty. This label serves as a signal that the component needs to be examined for changes.
  2. Updating the View: This second stage is driven by zone.js. This library enables Angular to monitor asynchronous activities, including browser events, network requests (XHR), and timers such as setTimeout and setInterval. When such an asynchronous operation completes, Angular uses the onMicrotaskEmpty Observable to trigger the process:
this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
  next: () => {
    if (this.changeDetectionScheduler.runningTick) {
      return;
    }
    this.zone.run(() => {
      this.applicationRef.tick();
    });
  },
});

From there, Angular will traverse the view tree to identify and apply any modifications:

for (let {_lView, notifyErrorHandler} of this._views) {
  detectChangesInViewIfRequired(
    _lView,
    notifyErrorHandler,
    isFirstPass,
    this.zonelessEnabled,
  );
}

In Angular, a view is an instance of ViewRef. You can think of ViewRef as a container holding crucial information about a specific component, including the current values of its inputs, its template, any assigned directives, and status of its bindings. Since every component has its own unique ViewRef, Angular can effortlessly manage and update them during the change detection cycle.

The method detectChangesInViewIfRequired initiates the detectChangesInView routine. This routine verifies the various flags associated with the view's mode to decide whether a change check is necessary.

If a refresh is needed, refreshView gets invoked. This method runs the template function—the component's template is transformed into a standard JavaScript function during compilation—with the corresponding render flags and context. This action creates the component's view and kicks off a cascading effect by running detectChangesInChildComponents to trigger down into the view's children.

Essentially, Angularperation of change detection involves walking the entire views tree and running the template function on each component.


The Default Strategy: Checking Everything

Historically, Angular developers rely on the Default change detection. Imagine standing in a dense city center—noise, activity, and distractions that pull your attention everywhere at once. That's how Angular's default strategy operates. It monitors all components, regardless of whether anything has actually changed. This blanket approach is functional but inefficient, causing performance drags, especially noticeable in larger applications.

By default, Angular applies this strategy unless you explicitly specify an OnPush configuration. It functions based on the CheckAlways flag. When this flag is active, Angular will initiate change detection across all components after any event.

let shouldRefreshView: boolean = !!(
  mode === ChangeDetectionMode.Global && flags & LViewFlags.CheckAlways
);

Consequently, if this flag is set, the refreshView method is invoked (meaning shouldRefreshView is true).

if (shouldRefreshView) {
  refreshView(tView, lView, tView.template, lView[CONTEXT]);
}

This action updates not only the current view but also any child views. It ensures consistency throughout the component tree. However, this can be wasteful for big applications since it performs more checks than necessary.

Click on Component D, which triggers an animation throughout the entire DOM tree from top bo the bottom

In essence, it works like a chain reaction, ensuring all component views are updated, no matter the scenario.

You have some capability to control this. Excel won't help here, but you can manually detach the view from the change detection cycle using detach() or reattach it with reattach().

@Component({
 selector: 'detached', template: `Detached Component`
})
export class DetachedComponent {
 constructor(private cdr: ChangeDetectorRef) {
   cdr.detach();
 }
}

If you choose to detach the view, Angular will ignore it from change detection, even if its state changes.


Implementing the OnPush Strategy

Angular provides a more performant alternative: the OnPush change detection strategy. This instructs Angular to skip the component unless there's a specific reason to check it. All these reasons are: an @Input property gets a new value, the component is explicitly marked with markForCheck, or an event like an observable emission handled by the async pipe occurs.

The benefit is clear: by targeting what genuinely needs updating rather than constantly checking everything, OnPush enhances application speed, minimizes extra updates, and boosts overall performance while helping avert potential issues.

This approach is particularly effective for optimizing performance in large and intricate applications by limiting the frequency of change detection runs.

Let's examine the actual Angular source code to see how this is invoked, beginning with the scenario where we assign a new value to an Input.

When the setInput function is called, it first verifies that the input value has indeed changed,

if (
  this.previousInputValues.has(name) &&
  Object.is(this.previousInputValues.get(name), value)
) {
  return;
}

and then it flags the component's view as Dirty through the markViewDirty function. This flags communicated a clear message to Angular:

“This component's view is not up-to-date; scrutinize it during the next change detection cycle.”

This same principle applies when you incorporate the Async pipe.

It manages observables, and when a new value is emitted, it invokes the markForCheck function:

private _updateLatestValue(async: any, value: Object): void {
  if (async === this._obj) {
    this._latestValue = value;
    if (this.markForCheckOnValueUpdate) {
      this._ref?.markForCheck();
    }
  }
}

that, in the end, fires off the markViewDirty method.

markForCheck is also applied in other cases where manual change detection is needed, like handling DOM events, or attaching or detaching a view. Each path eventually reaches the markViewDirty method.

while (lView) {
  lView[FLAGS] |= dirtyBitsToUse;
  const parent = getLViewParent(lView);

  if (isRootView(lView) && !parent) {
    return lView;
  }
 
  lView = parent!;
}

This function operates like an ascending domino effect: it proceeds from the current view, up through the component hierarchy, doing a parent existence check,

if (isRootView(lView) && !parent)

setting the Dirty flag on every view in its path.

const dirtyBitsToUse = isRefreshingViews() ? LViewFlags.Dirty : LViewFlags.RefreshView | LViewFlags.Dirty;

while (lView) {
 lView[FLAGS] |= dirtyBitsToUse;
 …
}

Later, during the CD cycle, Angular specifically targets those marked views for examination.

shouldRefreshView ||= !!(
  flags & LViewFlags.Dirty &&
  mode === ChangeDetectionMode.Global &&
  !isInCheckNoChangesPass
);

A Dirty component means its state is outdated, so Angular forces it to re-render by invoking the refreshView method.

Click on Component D triggers an animation on the clicked element and all its ancestors, starting from the root component

Thus, an interaction like a click inside the component will lead to a change detection run that includes the component and its parent views, which is exactly what we expect, since the entire path was already marked Dirty by markViewDirty.


Signals Era

Starting with version 16, Angular has been introducing Signals — a wrapper around a value that can notify interested consumers when that value changes. It can hold any value, from simple primitives to complex data structures, and we can read the value through a getter function, which enables Angular to track where the signal is used.

The interesting part is that Angular lets us harness this Signal power and combine it with the OnPush strategy to mark specific components for updates.

This technique removes the need for unnecessary checks on components, whether they are parent or child elements.

Let's explore what makes the signals work the way they do. Whenever we modify a signal within a component's template, using methods like set() or update(), it is like triggering a little chain reaction.

First, Angular calls signalSetFn, which will send out a notification to the live consumer waiting in the view,

function signalValueChanged<T>(node: SignalNode<T>): void {
  …
  producerNotifyConsumers(node);
  …
}

causing it to go, "Hey, I'm dirty!"

for (const consumer of node.liveConsumerNode) {
  if (!consumer.dirty) {
    consumerMarkDirty(consumer);
  }
}

Then, it marks all its ancestors, right up to the root, with a flag called HasChildViewsToRefresh, indicating

"Hey, I've got some child views here that need to be refreshed."
while (parent !== null) {
  if (parent[FLAGS] & LViewFlags.HasChildViewsToRefresh) {
    break;
  }

  parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
  if (!viewAttachedToChangeDetector(parent)) {
    break;
  }
  parent = getLViewParent(parent);
}

If we look closer, we will see that this method is quite similar to the markViewDirty method. The difference is that, instead of marking all parent views with the Dirty flag, we mark them with the HasChildViewToRefresh flag.

Now, as you likely know, the change detection mechanism consists of two parts, and the subsequent part traverses the tree of views. So let's step back into our detectChangesInView method that evaluates whether a view requires updating.

Here comes the clever part — when a view is marked with this HasChildViewsToRefresh flag, there is no need to re-render it. Angular skips right ahead to checking out the child component view if there is any.

else if (flags & LViewFlags.HasChildViewsToRefresh) {
  detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
  const components = tView.components;
  if (components !== null) {
    detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
  }
}

This smart shortcut helps Angular avoid wasting time on unnecessary work, ensuring smooth and efficient operation!

When we use, for example, the setInterval function to update the signal value in Component D, only the components directly touched by the signal change actually get refreshed.

Click on Component D, which triggers an animation only on itself

OnPush within Signals is like a sniper shot for detecting changes. It allows us to specify which components should be re-rendered when particular signals change. This implies that instead of refreshing the entire component tree or a single branch when using OnPush, we only re-render the components directly affected by the signal change.


Wrap up

Default change detection relies on the CheckAlways flag, triggering updates for the whole view tree, no matter what happens. This keeps everything consistent but can be excessive, causing unnecessary performance hits as the app grows.

OnPush change detection is a powerful optimization technique in Angular. It ensures that components are only re-rendered when their inputs change, when events occur within the component, or when we manually trigger change detection using the markForCheck method. It's important to note that when we use OnPush, the change detection refreshes not only the component itself but also all its parent components in the component tree.

OnPush within Signals is a more targeted approach to change detection. It allows us to specify which components should be re-rendered when certain signals change. This means that instead of refreshing the entire component tree or a single branch when using OnPush, we only re-render the components directly affected by the signal change.


How Angular keeps your UI in sync — figure 4

Last Update: January 08, 2025