Using Angular Signals and the OnPush Change Detection Strategy for Local Change Detection

The Angular ecosystem has seen a steady stream of enhancements in recent releases, and Angular v17 is no exception. The latest version shipped with a host of valuable upgrades, most notably the stabilization of signal APIs (with the exception of effect()), which have moved out of developer preview. Given the ongoing community focus on change detection performance, the arrival of stable signal APIs marks a significant step toward a more efficient and intelligent framework 🧠.

More details on the Angular v17 release can be found in the official announcement.

For some time, the Angular team has been exploring ways to introduce locality into the change detection process—that is, understanding the exact impact of a state change on the application, thereby reducing reliance on Zone.js. In Angular v17, signals have been refined to lay the groundwork for this locality-aware approach. This evolution, building on the existing top-down, Zone.js-driven model, gives rise to what the community refers to as Local Change Detection 🚀.

This capability, enabled by signals, promises performance improvements, but it raises several questions. Is this the feature developers have been eagerly awaiting? Does it reduce the number of checks during a change detection cycle? And how does it relate to the existing change detection strategies?

In this piece, we will explore how this locality is achieved, outline the conditions for its effective use, and demonstrate it with practical examples. Let’s get started 🐱‍🏍.

Change Detection Modes

Prior to Angular v17, whenever an event patched by Zone.js occurred—potentially leading to state changes—Zone.js would notify Angular that something had changed, without specifying where. As a result, Angular would initiate a change detection cycle, scanning the entire component tree and checking every component for changes. This all-encompassing method is known as the Global change detection mode.

With signals🚦, this approach is becoming more precise, eliminating the need to check every component. Signals can now track exactly where they are being read. When a signal is consumed in a component template, that template becomes a live consumer, updating whenever the signal’s value changes. Consequently, when a signal’s value is updated, only its direct consumers need to be marked as dirty, rather than all ancestor components as was the case pre-v17. This is achieved through a new enhancement, where signals are now intelligent enough to mark only the specific component (the consumer) as dirty, while marking its ancestor components for traversal via the HasChildViewsToRefresh flag.

To facilitate this, a new function markAncestorsForTraversal has been introduced, taking the place of markViewDirty (which previously marked all ancestor components as dirty as well). Here’s a look at the relevant source code:

export function consumerMarkDirty(node: ReactiveNode): void {
  node.dirty = true;
  producerNotifyConsumers(node);
  node.consumerMarkedDirty?.(node);
}

consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
  markAncestorsForTraversal(node.lView!);
},

export function markAncestorsForTraversal(lView: LView) {
  let parent = lView[PARENT];
  while (parent !== null) {
    // We stop adding markers to the ancestors once we reach one that already has the marker. This
    // is to avoid needlessly traversing all the way to the root when the marker already exists.
    if ((isLContainer(parent) && (parent[FLAGS] & LContainerFlags.HasChildViewsToRefresh) ||
         (isLView(parent) && parent[FLAGS] & LViewFlags.HasChildViewsToRefresh))) {
      break;
    }

    if (isLContainer(parent)) {
      parent[FLAGS] |= LContainerFlags.HasChildViewsToRefresh;
    } else {
      parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
      if (!viewAttachedToChangeDetector(parent)) {
        break;
      }
    }
    parent = parent[PARENT];
  }
}
Enter fullscreen mode Exit fullscreen mode

During change detection (triggered by Zone.js), when Angular encounters components marked for traversal, it recognizes that these components do not themselves need to be checked, but they do have dirty descendants that require attention. This ensures that these components are still visited during the traversal, allowing Angular to reach the dirty child components and refresh them efficiently. This new capability of signals to pinpoint the exact location of a change in the component tree gives rise to the "Local" change detection we mentioned earlier. This refined process is the Targeted change detection mode. Here, Angular still starts its top-down check (again, initiated by Zone.js), but it now only traverses through components marked for traversal, ultimately refreshing only the dirty consumers.

While this seems like a clear performance win, the question remains: is this behavior enabled by default, or does it require explicit opt-in?

Change Detection Strategies

Generally, the most effective way to tackle performance is to minimize the amount of work done—running less code, and in Angular’s context, reducing both the number of change detection cycles and the number of components checked within a cycle. To enable this, Angular needs a way to identify which components require checking and which can be skipped.

Because Angular cannot know which component changed, the default global, top-down process assumes all components in the tree are potentially dirty and checks them all on every cycle. This default behavior, where every component is checked regardless of its actual state, is referred to as the Default change detection strategy.

To optimize this and reduce Angular’s workload, the team introduced the OnPush change detection strategy. This strategy allows Angular to skip subtrees of components that haven’t been marked as dirty, significantly reducing the number of checks performed.

There are three conditions under which an OnPush component is marked dirty. You can find a detailed explanation in this article.

With this in mind, it seems plausible that the OnPush strategy is a prerequisite to benefit from v17’s Local change detection. Let’s see if that hypothesis holds up 💪.

Hybrid Change Detection

To illustrate the concept, here’s a small demo app that supports our case:

@Component({
  ...
  selector: 'app-child-y',
  templateUrl: `
    <div class="container">
      <h3>Child Y<br /> value: {{ count() }} runs: {{getChecked()}}</h3>

      <app-grandchild-y />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  ...
})
export class ChildYComponent {...}

@Component({
  ...
  selector: 'app-child-x',
  templateUrl: `
    <div class="container">
      <h3>Child X <br /> value: {{ count() }} runs: {{getChecked()}}</h3>

      <app-grandchild-x />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  ...
})
export class ChildXComponent {...}

@Component({
  ...
  selector: 'app-parent',
  templateUrl: `
    <div class="container">
      <h2>Parent <br /> value: {{count()}} runs: {{getChecked()}}</h2>

      <div class="children">
        <app-child-x />
        <app-child-y />
      </div>
    </div>
  `,
  ...
})
export class ParentComponent {...}
Enter fullscreen mode Exit fullscreen mode

The app displays a component tree. The Parent component uses the Default strategy, while its two children, ChildX and ChildY, both use the OnPush strategy. Each of these children has its own child, GrandChildX and GrandChildY, respectively, as shown below:

@Component({
  ...
  selector: 'app-grandchild-x',
  templateUrl: `
    <div class="container">
      <h4>(GrandChild X <br /> value: {{ count() }} runs: {{getChecked()}}</h4>

      <button (click)="updateValue()">Increment Count</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  ...
})
export class GrandchildXComponent {
  ...

  updateValue() {
    this.count.update((v) => v + 1);
  }
}

@Component({
  ...
  selector: 'app-grandchild-y',
  templateUrl: `
    <div class="container" appColor>
      <h4>(GrandChild Y <br /> value: {{ count() }} runs: {{getChecked()}}</h4>

      <button #incCount>Increment Count</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  ...
})
export class GrandchildYComponent implements AfterViewInit {
  ...

  @ViewChild('incCount') incButton!: ElementRef<HTMLButtonElement>;

  ngZone = inject(NgZone);
  injector = inject(Injector);
  app = inject(ApplicationRef);

  ngAfterViewInit(): void {
    runInInjectionContext(this.injector, () => {
      this.ngZone.runOutsideAngular(() => {
        fromEvent(this.incButton.nativeElement, 'click')
          .pipe(throttleTime(1000), takeUntilDestroyed())
          .subscribe(() => {
            this.count.update((v) => v + 1);
            this.app.tick();   
          });
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The only difference between the grandchild components is how their click event handlers are implemented. We’ll clarify the reasoning behind this distinction in the next section.

Now that both branches of the tree are using the OnPush strategy, let’s examine what occurs when you increment the count in the GrandChildX component:

Signals and OnPush with click event handler not getting benefits of local change detection in Angular v17

The behavior we observe here is no different from the Global strategy. Even with the OnPush strategy, both components are still being checked. Why does this happen?

Well, Angular internally uses a wrapper for event listeners that marks both the component and its ancestors for checking when an event fires. Furthermore, Zone.js monkey-patches the event, notifying Angular so it can start change detection. In our case, clicking the button marks the current component and all its ancestors as dirty. Since change detection starts in Global mode, the entire tree gets refreshed. This is the traditional pre-v17 behavior, which remains unchanged for backward compatibility.

From this, we can deduce that to leverage Local CD benefits, we must: update the signal in a manner that does not mark all ancestor components for checking.

Hmm🤔… I see. In the GrandChildY component, we adjusted the increment button to meet this requirement (credits to Thomas Laforge). Let’s observe the resulting behavior 👇:

Local Change Detection in Angular v17 with OnPush and Signals

And there it is 😊. We now see local change detection in action. To be precise: when the button is clicked, the signal’s value changes. This marks the current component (the consumer) as dirty and its ancestors for traversal. Zone.js (due to monkey-patching) is then triggered, notifying Angular. Angular initiates change detection in Global mode, refreshes the Parent component (which uses the Default strategy), then switches to Targeted mode when encountering components marked for traversal (those with the HasChildViewsToRefresh flag). With the OnPush strategy, it traverses ChildY without refreshing it, before finally reaching the dirty GrandChildY component (the consumer). At this point, it flips back to Global mode to refresh the view. This alternation between the Global and Targeted modes is termed Hybrid change detection 🐱‍🏍.

Interested in the internal mechanics? Consult the source code in the Angular repository.

One might argue that the scenarios where local change detection shines are limited. Indeed, the direct event-handling example above isn’t a common pattern. However, there are other, more frequent use cases where these benefits are realized (credits to Enea Jahollari), as shown below:

Shared signal state in a service or ngrx store when changed only OnPush components will be checked, thus we have local change detection

Here, we see a more typical situation: a shared state in a service, a common pattern in state management libraries (NgRx, Akita, …), consumed by many components across the tree. Or perhaps some state on the parent consumed by its children. When this state (remember, it’s a signal value ⚠) updates, only the consuming components are marked dirty. With the OnPush strategy, you instantly gain the advantages of local change detection.

This is a classic example. If you think of other scenarios where local change detection would be beneficial, please share them with me and the community.

Conclusion

Improving change detection has always been a major focus for the Angular team and community. The locality we see in v17 isn’t the final solution, but it’s a solid foundation and a crucial first step toward future, more advanced implementations. While this isn’t enabled by default, developers can still unlock the benefits of local change detection in their apps with minimal adjustments: the OnPush change detection strategy combined with Signals. To stay updated on the team’s reactivity plans, check out the published roadmap 🔥.

Here’s a summary of everything discussed in this article:

Visual presentation of local change detection in Angular v17

You can find and experiment with the complete code here: https://stackblitz.com/edit/local-cd-angular-17?file=src%2Fmain.ts

Additional Resources

For further reading on local change detection in v17, make sure to check out these resources.

Special thanks to @kreuzerk, @eneajaho, @tomastrajan, and Matthieu Riegler for their review.

Thanks for reading!

I hope you enjoyed this post 🙌. If you found it useful, feel free to share it with your network.
For any questions or feedback, please leave a comment below 👇.
If this article was valuable and you wish to stay updated on future posts, follow me on @lilbeqiri, dev.to, or Medium. 📖