Subscribing to DOM Events Behind *ngIf

Viewed 724

I like to be able to handle DOM events as an observable stream, calling filter, concatMap, etc. like the following example.

@Component({
  template: `<button #btn>Submit<button`,
  selector: 'app-test',
})
class TestComponent implements AfterViewInit, OnDestroy {

  private destroy$ = new Subject<boolean>();

  @ViewChild('btn') private btn: ElementRef<HtmlButtonElement>;

  constructor(private testService: TestService) { }

  ngAfterViewInit() {
    fromEvent(btn.nativeElement, 'click').pipe(
      exhaustMap(() = > {
        return this.testService.save();
      }),
      takeUntil(this.destroy$),
    ).subscribe();
  }

  ngOnDestroy() {
    this.destroy$.next(true);
    this.destroy$.complete();
  }
}

But sometimes the element I want to listen for events from is behind an *ngIf and doesn't exist when ngAfterViewInit runs. What's the best way to still listen for events in a reactive way?

One way I tried was to set up the same subscription as above in ngOnViewChecked behind an if statement that checked if the ElementRef exists, and with a flag to avoid multiple subscriptions. But I found that messy.

Is it a good practice to do something like this?

@Component({
  template: `<button (click)="clickEvent.emit()">Submit<button`,
  selector: 'app-test',
})
class TestComponent implements OnInit, OnDestroy {

  private destroy$ = new Subject<boolean>();

  clickEvent = new EventEmitter<void>();

  constructor(private testService: TestService) { }

  ngOnInit() {
    this.clickEvent.pipe(
      exhaustMap(() = > {
        return this.testService.save();
      }),
      takeUntil(this.destroy$),
    ).subscribe();
  }

  ngOnDestroy() {
    this.destroy$.next(true);
    this.destroy$.complete();
  }
}

Should I replace the EventEmitter with a Subject? Is there a better way that any of these?

edit: just to be clear, my question has to do with subscribing to events from elements that might not exist due to an *ngIf

3 Answers

Use @ViewChildren with a QueryList to listen to DOM changes.

import { Component, AfterViewInit, OnDestroy, ElementRef, ViewChildren, QueryList } from '@angular/core';
import { Subject, fromEvent, of } from 'rxjs';
import { exhaustMap, takeUntil, switchMap, map, filter } from 'rxjs/operators';

@Component({
  selector: 'app-test',
  template: `<button #btn>Submit<button`
})
export class TestComponent implements AfterViewInit, OnDestroy {

  private destroy$ = new Subject<boolean>();

  // Any time a child element is added, removed, or moved, 
  // the query list will be updated, and the changes observable 
  // of the query list will emit a new value.
  @ViewChildren('btn') private btn: QueryList<ElementRef>;

  constructor(private testService: TestService) { }

  ngAfterViewInit() {
    this.btn.changes.pipe(
      // only emit if there is an element and map to the desired stream
      filter((list: QueryList<ElementRef>) => list.length > 0),
      switchMap((list: QueryList<ElementRef>) => fromEvent(list.first.nativeElement, 'click')),
      // add your other operators
      exhaustMap(() => this.testService.save()),
      takeUntil(this.destroy$),
    ).subscribe(console.log);
    // Trigger a change event to emit the current state
    this.btn.notifyOnChanges()
  }

  ngOnDestroy() {
    this.destroy$.next(true);
    this.destroy$.complete();
  }
}

https://stackblitz.com/edit/angular-ivy-iklbs9

with @ngneat/until-destroy you can do as follow

    npm install @ngneat/until-destroy
    import { Component } from "@angular/core";
    import { Subject, of } from "rxjs";
    import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
    import { exhaustMap } from "rxjs/operators";  

    @UntilDestroy()
    @Component({
      template: `<button (click)="clickStream.next()">Submit</button>`,
      selector: 'app-test',
    })
    class TestComponent {

      clickStream = new Subject<void>();

      constructor(private testService: TestService) {

        this.clickStream.pipe(
          untilDestroyed(this),
          exhaustMap(() => {
            return this.testService.save();
          })
        ).subscribe();
      }
    }

for angular below version 9 you can use ngx-take-until-destroy mostly in same way

I'd first create a directive so that I can have access to the DOM element whose events I want to listen and also be notified when that directive is destroyed, so I can properly remove the listener when needed:

@Directive({
  selector: '[notify-on-destroy]'
})
export class NotifyOnDestroyDirective {
  @Output()
  destroy = new EventEmitter();

  constructor(public elem: ElementRef) { }

  ngOnDestroy () {
    this.destroy.emit();
  }
}

And here's the component:

my-comp.component.html

<button (click)="isShown = !isShown">toggle shown state</button>

<br><br><br>

<button 
  *ngIf="isShown" 
  notify-on-destroy 
>
  click me!
</button>

my-comp.component.ts

isShown = false;

private eventSources = new Subject<Observable<any>>();

@ViewChild(NotifyOnDestroyDirective) 
set btn (v: NotifyOnDestroyDirective) {
  if (!v) {
    return;
  }

  const src$ = fromEvent(v.elem.nativeElement, 'click').pipe(
    finalize(() => console.log('ev unsubscribed')),
    takeUntil(v.destroy)
  );

  this.eventSources.next(src$);
}

ngOnInit () {
  this.eventSources.pipe(
    mergeAll(),
  ).subscribe(() => console.log('event occurred'))
}

As you can see, I'm using a Subject whose values are observables. That's because I want to handle the observable's creation right in the setter.

mergeAll() will subscribe to all the incoming observables. With this approach, you can apply this logic to multiple elements as well.

ng-run demo

Related