Sunday, September 15, 2024

Respond to DOM Changes in Angular with MutationObserver Web API

In the Respond to DOM Changes with Mutation Observers article, we learned about DOM Mutation Observers, which replaced the clunky DOM Mutation Events. Both Mutation Events and Mutation Observers were designed to inform your script of changes to the DOM structure. These include:

  • the adding or removing of elements
  • changes to an element’s attributes
  • character data changes (i.e., changes to a text node)

Looking at the above list, it quickly becomes apparent that Mutation Observers can also help Angular developers respond to DOM events that would remain elusive otherwise. In this tutorial, we will see just how easy it is to harness the power of the MutationObserver Web API to receive updates whenever a Component’s child elements change.

Introducing the Singles Catalog Management Console App

Here’s an Angular app for managing the cover art for a catalog of digital music singles:

DOM Changes in Angular Tutorial

 

It’s a very simple app that allows us to add and remove items from an unordered list. Unsurprisingly, there is not a whole lot of HTML markup required to make it work:

<h1>Singles Catalog</h1>
<ul>
  <li *ngFor="let artwork of singlesArtwork">
  <img [src]="artwork" /><button (click)="removeArtwork(artwork)">Remove</button>
  </li>
</ul>
<button *ngIf="showAddButton; else showMessage" (click)="addArtwork()">Add Next Single</button>

<ng-template #showMessage>No more singles to add.</ng-template>


Read:
Declare and Respond to a Dom Element Ready Event

Creating an AppService in Angular

The images are served up from a service via the public fetchSinglesArtwork() method. It removes elements from the start of the list and emits them as an RxJS Observable. A delay of 500 milliseconds is added in order to emulate a network call that would be employed to fetch image URLs from an external data store. It also has a method to remove images that replaces previously removed items:

import { Injectable } from '@angular/core';
import * as Rx from 'rxjs/Rx';

const ROOT = 'https://i1.sndcdn.com/artworks-';

@Injectable()
export class AppService {
  private singlesArtwork = [
    ROOT + 'nOmzKy8phBjdHggR-B4aC8Q-t200x200.jpg', 
    ROOT + 'b5jyk6LpdxWVWWz5-jGpxXw-t200x200.jpg', 
    ROOT + 'OCRwxXbSBQwex4mM-LY2qWg-t200x200.jpg',
    ROOT + '2n1JKCcFA6Gg6UH7-GsP3HA-t200x200.jpg',
    ROOT + 'NXjocVbpYjLOCwSx-9VELtA-t200x200.jpg'
  ];

  public fetchSinglesArtwork(howMany: number = 1): Rx.Observable<string[]> {
    return Rx.Observable
      .of(this.singlesArtwork.splice(0, howMany))
      .delay(500);
  }

  public removeSinglesArtwork(artwork: string): void {
    this.singlesArtwork.push(artwork);
  }
}

Fetching the Initial List

On startup, our app shows the first three images. To fetch them, the AppComponent injects our service into the constructor and invokes its fetchSinglesArtwork() method. In the subscription callback function, the URLs to the singles artwork are stored in the public singlesArtwork variable that is referenced from the template. It is a Set rather than a regular array so that we can guarantee that the list never contains duplicates. Of course, if the service is coded properly, that should never happen.

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private singlesArtwork: Set<string>;
  public showAddButton = true;

  constructor(private appService: AppService) { 
    appService.fetchSinglesArtwork(3)
      .subscribe(singlesArtwork => this.singlesArtwork = new Set<string>(singlesArtwork));
  }
  // more code...
}

Read: RXJS Observables Primer in Angular

Adding an Item to the List

Clicking the Add Next Single button invokes the addArtwork() method, which in turn calls fetchSinglesArtwork(). In the addArtwork() subscription callback function, the artwork is added as long as there are elements left in the array; otherwise, the Add button is replaced by a message stating that there are no more singles to add:

public addArtwork(): void {
  this.appService
    .fetchSinglesArtwork()
    .subscribe(singlesArtwork => {
      if (singlesArtwork.length > 0) {
        this.singlesArtwork = this.singlesArtwork.add(singlesArtwork[0]);
      } else {
        this.showAddButton = false;
      }
    });
}

Removing an Item from the List

Beside each image, there is a button to remove it from the list that invokes the removeArtwork() method. It, in turn, calls the service’s removeSinglesArtwork() method, passing along the image URL so that the missing data may be replaced in the AppService’s singlesArtwork array:

public removeArtwork(artwork: string): void {
  this.singlesArtwork.delete(artwork);
  this.appService.removeSinglesArtwork(artwork);
  this.showAddButton = true;
}

The DomChange Directive in Angular

In Angular applications, the MutationObserver API is probably best implemented as a directive. The ElementRef is injected as a constructor parameter so that we can access the node element directly. Then every DOM change emits a custom event that passes the change itself as an argument:

import { Directive, ElementRef, EventEmitter, OnDestroy, Output } from '@angular/core';

@Directive({
  selector: '[domChange]'
})
export class DomChangeDirective implements OnDestroy {
  private changes: MutationObserver;

  @Output()
  public domChange = new EventEmitter();

  constructor(private elementRef: ElementRef) {
    const element = this.elementRef.nativeElement;

    this.changes = new MutationObserver((mutations: MutationRecord[]) => {
        mutations.forEach((mutation: MutationRecord) => this.domChange.emit(mutation));
      }
    );

    this.changes.observe(element, {
      attributes: true,
      childList: true,
      characterData: true
    });
  }

  ngOnDestroy(): void {
    this.changes.disconnect();
  }
}

We can now bind the directive to the onDomChange() handler in the template as follows:

<ul (domChange)="onDomChange($event)">

In this case, the $event will be an ElementRef to the <ul> element.

In the AppComponent’s onDomChange() handler, we can respond to the DOM changes any way we wish. For demonstration purposes, we’ll just look for the addition and removal of <li> elements and output the first one to the console:

public onDomChange(mutationRecord: MutationRecord): void {
  const addedNodes   = mutationRecord.addedNodes;
  const removedNodes = mutationRecord.removedNodes;

  if (  addedNodes.length > 0
    && (addedNodes.item(0) as HTMLElement).tagName === 'LI') {
    console.log('Added nodes: ', addedNodes.item(0));
  } else if (removedNodes.length > 0
         && (removedNodes.item(0) as HTMLElement).tagName === 'LI') {
    console.log('Removed nodes: ', removedNodes.item(0));
  }
}

Note that both the addedNodes and removedNodes elements are Node objects in TypeScript, so we have to cast them as an HTMLElement in order to gain access to its attributes and methods. We can see some sample output here:

Dom and Angular Tutorials

 

You’ll find the Singles Catalog application on stackblitz.

Conclusion

Thanks to the MutationObserver Web API, we can do things in our Angular apps that would be very difficult to otherwise. Just be careful to respect the component hierarchy and avoid listening to changes within other components. For instance, if you need to respond to DOM changes within a child component, add the directive to its template and then pass up the data you need to the parent via an EventEmitter.

Read: Graceful RxJS Error Handling

Rob Gravelle
Rob Gravelle
Rob Gravelle resides in Ottawa, Canada, and has been an IT guru for over 20 years. In that time, Rob has built systems for intelligence-related organizations such as Canada Border Services and various commercial businesses. In his spare time, Rob has become an accomplished music artist with several CDs and digital releases to his credit.

Get the Free Newsletter!

Subscribe to Developer Insider for top news, trends & analysis

Popular Articles

Featured