DEV Community

Stephen Cooper
Stephen Cooper

Posted on • Updated on

Change Detection: Getting in the (Angular) Zone!

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.

Top comments (9)

Collapse
 
evanplaice profile image
Evan Plaice • Edited

The issue here is chartData is a complex data type (ie reference based).

Angular can't see the change b/c it's watching the observable's reference. Not, it's internal data.

If you changed chartData to update with the contents of the observable rather than the observable itself, you won't need to touch the zone.

That's why you see the spread-shallow-copy pattern used a lot in React.

chartData = [...newChartData]

It creates a new complex object and -- therefore -- a new reference value, guaranteeing change detection is triggered.

Here's an article that digs deeper, specifically the "How does the default change detection mechanism work?" section

blog.angular-university.io/how-doe...

Collapse
 
scooperdev profile image
Stephen Cooper

Hi Evan, thanks for your explanation. I left out large chunks of the code but I am using the spread operator in my NgRx reducer for exactly the reason you mentioned. So the chartData observable is a new reference each time in my component.

Collapse
 
evanplaice profile image
Evan Plaice

👍

Collapse
 
scooperdev profile image
Stephen Cooper

As I am using NgRx in this current app and we do not run anything purposefully outside of NgZone we can add a check. The following meta reducer checks that every event that updates the store is in Angular's zone. Reverting my fix and adding this makes it very easy to locate the source of my issue.

//NgRx Meta Reducer to assert always in NgZone
export function assertInNgZoneMetaReducer(reducer) {
    return function(state: State, action: Action): State {
        NgZone.assertInAngularZone();
        return reducer(state, action);
    };
}
Collapse
 
scooperdev profile image
Stephen Cooper
Collapse
 
monfernape profile image
Usman Khalil

I loved your explanation.

Collapse
 
scooperdev profile image
Stephen Cooper

Thanks! Glad it made sense.

Collapse
 
jialipassion profile image
JiaLiPassion

Could you provide a reproduce repo for this issue? There maybe another way to handle the issue like this. Thanks.

Collapse
 
scooperdev profile image
Stephen Cooper

I will try and put something together when I get back into the office next week.