03/29/2024
Please be careful when listening to scroll events in Angular
Recently I stumbled about a tweet where somebody showed an example to create dynamically a sticky navbar in Angular:
I was curious and had a look at the code. which looked something like this:
@Component({
template: `
<nav class="py-4 px-8 "
[ngClass]="{'sticky-header': (isSticky$ | async) === true}"
>
<div class="...">
<div class="...">Your Logo</div>
<ul class="...">
<li><a href="#" >Home</a></li>
<li><a href="#" >About</a></li>
<li><a href="#" >Services</a></li>
<li><a href="#" >Contact</a></li>
</ul>
</div>
</nav>
`,
})
export class NavBarComponent {
public readonly isSticky$ = fromEvent(window, 'scroll').pipe(
map((x) => window.scrollY),
distinctUntilChanged(),
map((scrollTop: number) => scrollTop > 24)
);
}
Whenever the isSticky$
observable emits and the scroll position is greater than 24, the sticky-header
class is added to the navbar.
But why do I care to write an article about this? The code looks fine, doesn’t it?
Please note that I will not link the original tweet or source code. It is not my intention to blame someone else’s work. I am writing this article only because of educational purposes and to give my tiny input to the community to make your Angular applications a little bit better.
🎯 The problem
Recapturing how change detection in Angular works, we know that Angular runs the change detection cycle whenever a event of a zone.js patched event is fired.
I will not go in detail how zone.js works, but in short, zone.js patches all browser events to run the change detection cycle. If you are interested in this topic, I kindly recommend the awesome linked resources below.
One of these patched events is the scroll
event. So whenever the scroll
event is fired, Angular will trigger it’s change detection. We are listening to the scroll event in the fromEvent
. As a sidenote: it would be the same if we would use @HostListener('window:scroll')
alternatively.
Looking againg at the code we can see that our observable emits on every pixel we scroll. And on every pixel we scroll, change detection is executed and by default the whole applications component tree is re-rendered.
Even in simple applications we can cause by this massive performance issues.
Visualizing the problem
In the gif above you will notice two circles with numbers inside. One circle is placed in the NavbarComponent
and the other one in the AppComponent
. The numbers represent the amount of change detection cycles that are executed.
As you can see it does increse constantly when scrolling from top to bottom and back. To be precise: on every pixel scrolled.
🔍 Get out of Angular’s (comfort) zone
How can we solve this issue you may ask? Is a complicated refactoring needed?
The answer is No! We only need to restructure our code a little bit and detach our scroll-listener from Angular’s zone:
@Component({
// no changes here
})
export class NavBarComponent {
private readonly isSticky$$ = new BehaviorSubject(false)
readonly isSticky$ = this.isSticky$$.asObservable()
private readonly zone = inject(NgZone);
private readonly cdr = inject(ChangeDetectorRef);
constructor() {
this.zone.runOutsideAngular(() => {
const scroll$ = fromEvent(window, 'scroll').pipe(
map((x) => window.scrollY),
distinctUntilChanged(),
map((scrollTop: number) => scrollTop > 24),
distinctUntilChanged(),
);
scroll$.subscribe((s) => this.setSticky(s));
});
}
setSticky(s: boolean) {
this.isSticky$$.next(s)
this.cdr.detectChanges();
}
}
Let’s go through the changes step by step:
- We create a new
BehaviorSubject
isSticky$$
which will hold the sticky state of the navbar. We also create a public observableisSticky$
which we will use in the template (like before). - We inject
NgZone
andChangeDetectorRef
to our component. - In the constructor we run the scroll listener outside of Angular’s zone. This means that the change detection cycle is not triggered when the scroll event is fired. We
unpatched
this particular event from zone.js. - We subscribe to the
scroll$
observable and call thesetSticky
method whenever the observable emits. - In the
setSticky
method we set the value of theBehaviorSubject
and manually trigger the change detection cycle so that our view is updated.
Please note that in the improved code above I only concentrate on the relevant parts to solve the particular problem. For simplicity reasons I did not care about handling the subscription when subscribing to the scroll$
observable. Of course you should always handle subscriptions to avoid memory leaks.
Did it work?
✅ Hurray! We can see that now the numbers are only increasing on actual changes to the sticky state of the navbar.
Demo
Here is a Stackblitz where you can see the optimized code in action.
Resources on zone.js and change detection in Angular
- https://angularindepth.com/posts/1053/everything-you-need-to-know-about-change-detection-in-angular
- https://blog.thoughtram.io/angular/2016/02/01/zones-in-angular-2.html
- https://christiankohler.net/reactive-angular-with-ngrx-component
- https://www.youtube.com/live/HTU4WYWGTIk?si=LjICYyAMhs1DuiaM&t=818
If you have any questions or feedback, feel free to reach out to me on Twitter. I am always happy to help you out.
If you are looking for support on architecture, testing and performance-optimization, feel free to contact me. I am always open for new opportunities. Please check out my website for more information.