Change Detection: Getting in the (Angular) Zone!

Stephen Cooper - Jan 24 '20 - - Dev Community

Who knew an event binding in one component could cause a display bug in another? We will explain the issue before showing how using NgZone in the right place resolved it.

Setting the Scene

We have a chart in our app to display data based on the user's selection. The work flow is as follows:

  1. User makes a selection in a dropdown.
  2. On closing the dropdown the selection is emitted.
  3. An api call is triggered returning the data.
  4. Chart updates to display the data.

However, following a change I made this week (I removed a CPU draining setInterval) the api call would return, but the chart would not update. Well not update until the user interacted with the page. Clearly this is a terrible user experience!

Observable Firing but Template Not Updating

I could easily confirm that the updated data was arriving at the ChartComponent by tap'ing the observable pipe and logging the data.

chartData$ = this.data.pipe(
    tap(data => console.log("Data updated", data)
);
Enter fullscreen mode Exit fullscreen mode

So why wasn't the async pipe in my template updating the chart? And why does the data 'suddenly' appear when the user interacts with the page?

<chart [data]="chartData$ | async"></chart>
Enter fullscreen mode Exit fullscreen mode

Whenever you run into a situation like this you can be pretty sure you have a change detection issue. In this case Angular is failing to run a change detection cycle after the data has been updated. But why!?

NgZones

If you are not familiar with Zones in Angular it will be worth reading that first. In summary asynchronous tasks can either run inside or outside of Angular's change detection zone. The delayed update suggests the event to update the chart is running outside of Angular's zone. However, our ChartComponent has no ngZone reference and usually you have to be explicit to run a task outside of Angular's zone?

It's all about the source event

What took me sometime to discover was that I should not be looking at the end of the data pipeline but at the start. In particular at the event that kicks off the update.

Any event started outside of Angular's zone will run to completion outside without ever triggering change detection. No change detection, means no updates to our templates. This is sometimes desired for performance but we won't go into that here.

If we trace our chart update back through the api call, the NgRx Effect, the NgRx Action back to the dropdown Output event and finally to the eventEmitter inside the component I discovered the following code.

@Component({...})
export class DropdownComponent implements OnInit {

    @Output()
    updateSelection = new EventEmitter<any>();

    ngOnInit(){
        $('#dropdown').on('hidden.bs.dropdown', () => {
            this.updateSelection.emit(this.selections);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

jQuery Event Handler

This code uses jQuery to watch for the hidden event of a Bootstrap dropdown. This enables the component to fire an event when the dropdown is closed. The critical thing to note is that the Bootstrap hidden.bs.dropdown is fired outside of Angular's zone. Despite the fact we use an @Output EventEmitter this entire chain of events is run outside of Angular's zone.

This means that any side effects of this event will not be reflected in our template! This is exactly what we were seeing with out chart not updating. The data would 'suddenly' appear when some other event triggers a change detection cycle causing our chart to update at that point in time.

Solving with NgZone

To fix this issue we need to make Angular aware of this event. We do this by wrapping the EventEmitter in the ngZone.run() method as follows.

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

    constructor(private ngZone: NgZone) {}

    ngOnInit(){
        $('#dropdown').on('hidden.bs.dropdown', () => {
            this.ngZone.run(() => {
                // Bring event back inside Angular's zone
                this.updateSelection.emit(this.selections);
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

This means the event is now tracked by Angular and when it completes change detection will be run! As we have applied this fix within our DropdownComponent all subsequent events forked off this originating one will also be checked. Important when using NgRx Actions and Effects!

Fixing the ChartComponent the wrong way

My first approach to fixing this issue was to use this.ngZone.run() in my ChartComponent. While this fixes the chart, we would still be at risk of display inconsistencies!

For example, when an api call fails we display an error message to the user. With the fix only made in the ChartComponent this error message would not be displayed until the next change detection cycle. We could make the same fix in the ErrorComponent but now we are littering our code and who knows how many other times we will need to apply this fix.

In our case, it is important to bring the event back into Angular's zone as soon as possible. Otherwise every time this DropdownComponent is used we will have to repeat the fix.

Why did I not notice this issue before?

This bug appeared when I remove a CPU intensive setInterval from another part of my app. It turns out, thanks to zone.js, setInterval fires events that are automatically within Angular's zone resulting in change detection. As the interval was set to 500ms our chart would only ever be 500ms out of date, hence why we did not notice this before. Not only have we fixed the underlying dropdown issue, we have a performance improvement too!

Summary

Watch out for delayed template updates as they point to an event firing outside of Angular's zone. Secondly, don't rush to apply a fix before understanding the route cause, especially when it comes to change detection. As proof check out another time when the quick fix was not my best option.

Note about HostListener

If I could have used a HostListener, as suggested by Isaac Mann and Wes Grimes on Twitter, to capture the Bootstrap event then there would have been no need for NgZone. However, as explained in the thread I could not make this work.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .