6

I'm having major trouble trying to perform some logic inside a (click) event callback function in Angular 2 without triggering change detection.

Why I don't want to trigger change detection

The callback function performs a simple animated scroll to top. It effects no other components and therefore I don't need change detection to fire in every component, in fact I really don't want it to fire! Because change detection does fire in every component, the performance of the animation on mobile is extremely poor.

What I've tried

I know I can run code outside of the zone using

this._zone.runOutsideAngular(() => {
    someFunction();
})

This works nicely when the code you want to run outside the zone isn't invoked by a click, keyup, keydown event etc.

Because my component looks like...

<button (click)="doThing()">Do that thing outside the zone!</button>

doThing() {
    this._zone.runOutsideAngular(() => {
        myFunction();
    })
}

...myFunction will be run outside the zone but the click event will trigger change detection.

I've already implemented OnPush change detection strategy in as many of my components as possible.

It's important that change detection is not triggered here but I can't find a way to prevent it.

Edit See this plnk with change detection set to OnPush in all components. Clicking the subrow component button triggers CD in the subrow, row and app components.

garethdn
  • 12,022
  • 11
  • 49
  • 83
  • have you tried to use `changeDetection: ChangeDetectionStrategy.OnPush` in that component's `@Component({})` decorator??? – micronyks Sep 27 '16 at 16:33

3 Answers3

10

As noted, even setting the component in which the click event occurs to OnPush will still result in change detection being triggered.

Out-of-the-box event handling

@Component({
    selector: 'test'
    template: `<button (click)="onClick($event)">Click me</button>`,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent {
    
    onClick(e:Event) {
        e.preventDefault();
        e.stopPropagation();

        this._zone.runOutsideAngular(() => {
            // gets run outside of Angular but parent function still triggers change detection
        });

        return false;

        /*
         * change detection always triggered
         * regardless of what happens above
         */
     }

 }

Most of the time this may not be a problem but when rendering and painting performance for certain functions is essential I'm taking the approach of entirely by-passing Angular's built-in event handling. From my own experiments the following seems to be the easiest and most maintainable way to approach this.

By-pass out-of-the-box event handling

The idea here is to use RxJS to manually bind to whatever events I want, in this case a click, using fromEvent. This example also demonstrates the practice of cleaning up the event subscriptions.

import { Component, ElementRef, OnInit, OnDestroy, NgZone } from '@angular/core';
import { takeUntil } from "rxjs/operators";
import { Subscription, fromEvent, Subject } from 'rxjs';

@Component({
    selector: 'test'
    template: `<button #myButton>Click me</button>`,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent implements OnInit {

    private _destroyed$: Subject<null> = new Subject();

    // reference to template variable
    @ViewChild('myButton') myButton: ElementRef<HTMLButtonElement>;

    constructor(private _zone: NgZone){}

    ngOnInit() {
        this.subscribeToMyButton();
    }

    subscribeToMyButton() {
        this._zone.runOutsideAngular(() => {
            fromEvent(this.myButton.nativeElement, 'click')
                .pipe(takeUntil(this._destroyed$))
                .subscribe(e => {
                    console.log('Yayyyyy! No change detection!');
                });
        });
    }

    ngOnDestroy() {
        // clean up subscription
        this._destroyed$.next();
        this._destroyed$.complete();
    }

}

Hopefully this will be some help to others in a similar situation.

garethdn
  • 12,022
  • 11
  • 49
  • 83
1

If you use OnPush

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})

then change detection should be limited to the component where the click event happens.

You can detach the ChangeDetectorRef

  constructor(private ref: ChangeDetectorRef, private dataProvider:DataProvider) {
    ref.detach();
  }

Change detection won't be run for this component until it is reattached.

Günter Zöchbauer
  • 623,577
  • 216
  • 2,003
  • 1,567
  • I'm not sure this is the case. I'll edit the original post with a poorly formatted plnk! – garethdn Sep 27 '16 at 17:07
  • `detatch()` seems to prevent change detection https://plnkr.co/edit/wVjjGhcZkqVbtsVjvZyi?p=preview. – Günter Zöchbauer Sep 27 '16 at 17:19
  • I also tried `preventDefault()` and `stopPropagation()` on the event but that didn't prevent change detection to run on all components. – Günter Zöchbauer Sep 27 '16 at 17:20
  • Likewise. I tried those two as well but to no avail. I'll look further into `detach` but right now my best option, I think, is using RxJs's `Observable.fromEvent` to manually listen to clicks on my element and run the callback outside Angular. The problem with `detach` being that as soon as I `reattach` it's going to run change detection again. – garethdn Sep 27 '16 at 17:22
1

I can show one total hack but seems it works.

sub-row-component.ts

constructor(private zone: NgZone, private app: ApplicationRef) {}

rowClick() {
  console.log('rowClick');
  var a = 4;

  let originalTick = (<any>this.app).constructor.prototype.tick;
  (<any>this.app).constructor.prototype.tick = () => console.info('tick tick tick :)');
  const subscriber = this.zone.onMicrotaskEmpty.subscribe({
    next: () => { 
      console.info('tick has been stopped. So we can return everything in its place');  
      subscriber.unsubscribe();
      (<any>this.app).constructor.prototype.tick = originalTick;
  }});
}

Demo Plunker

yurzui
  • 205,937
  • 32
  • 433
  • 399