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:
- User makes a selection in a dropdown.
- On closing the dropdown the selection is emitted.
- An api call is triggered returning the data.
- 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)
);
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 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);
});
}
}
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);
});
});
}
}
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)
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...
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.
👍
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.
As of v9 this is now built in!
NgRx 9: Introducing strictActionWithinNgZone runtime check
Stephen Cooper ・ Mar 10 ・ 3 min read
I loved your explanation.
Thanks! Glad it made sense.
Could you provide a reproduce repo for this issue? There maybe another way to handle the issue like this. Thanks.
I will try and put something together when I get back into the office next week.