03/29/2024

Please be careful when listening to scroll events in Angular

post banner

Recently I stumbled about a tweet where somebody showed an example to create dynamically a sticky navbar in Angular:

Sticky Navbar

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

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:

  1. We create a new BehaviorSubject isSticky$$ which will hold the sticky state of the navbar. We also create a public observable isSticky$ which we will use in the template (like before).
  2. We inject NgZone and ChangeDetectorRef to our component.
  3. 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.
  4. We subscribe to the scroll$ observable and call the setSticky method whenever the observable emits.
  5. In the setSticky method we set the value of the BehaviorSubject 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?

Visualizing the solution ✅ 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

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.


post banner
Michael Berger

About the Author

I am a seasoned Fullstack Software Engineer with a focus on building high-performant web applications and making teams better.

If you are looking for support on building a scalable architecture that grows with your business-needs, revisit and and refine your testing-strategy as well as performance-optimizations, feel free to reach out to me.

I am always looking for new challenges and interesting projects to work on. Check out my website for more information.