Change detection: Getting in the (Angular) zone!

Who knew that 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. Then 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. The 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, it wouldn’t 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)
);

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>

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 would be worth your while to read that first. In summary, asynchronous tasks can either run inside or outside of Angular’s change detection zone. The delayed update suggests that the event responsible for updating 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 some time 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.

Tracing the 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);
        });
    }
}

jQuery Event Handler

This code uses jQuery to watch for the hidden event of a Bootstrap dropdown, which 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 that 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 our 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);
            });
        });
    }
}

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. That’s an important concept to remember when using NgRx Actions and Effects! (See this NgRx issue for a runtime check to help catch these issues)

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 removed a CPU intensive setInterval from another part of my app. It turns out, thanks to zone.js, that setInterval fires events that are automatically within Angular’s zone, which results in change detection. As the interval was set to 500ms, our chart would only ever be 500ms out of date. This is why we did not notice it 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:

Change Detection: When using setTimeout() is not your best option

Free Resources