04 March 2020

Angular: Sharing HTML Elements between Components using an Element Manager Service

As with all topics related to software development, there is always more than one solution to a problem. The following proposition is simply one possible solution and not intended to be definitive.

Summary:

  • Create an Element Manager Service for storing shared HTML Elements
  • Register HTML Elements with the Element Manager Service
  • Retrieve HTML Elements from the Service using RxJS BehaviourSubject

 

By design, Angular discourages manipulating HTML elements that are not directly related to a specific component or directive. Angular also abstracts DOM access and manipulation by providing interfaces and wrappers within components. These patterns are important and necessary for keeping a clear separation of concerns and enabling isolated unit testing.

However, often within a single Angular application, which becomes an amalgamation of many shared components, a few larger feature components and particularly when a component hierarchy is established, accessing HTML Elements outside a single component is unavoidable.

For example, perhaps you have a parent component that represents the main layout of the application (header, Sidebar, footer), within that layout component you have a child content component. The child component may need to toggle the sidebar, or possibly lazy-load content based on the parent components scroll position. Traditionally, you might do something like this:

let element = document.querySelector('#layout');

Even in the context of Typescript and Angular, this'll probably work... But this type of DOM manipulation is in contrast to Angular's base principals and intended patterns.

Generally, when you need to access DOM Elements, you would use the @ViewChild annotation to access the DOM Element:

@ViewChild('layout')
public layout: ElementRef;

This is a solid pattern that feels clean and consistent, but by definition this pattern is used to access a child view or, expressed more simply, access an HTML Element contained within the component.

Just like one might share HTTP resources or state variables between components using a service, it's feasible and effective to share HTML Elements using a service. We can do this be creating a service that follows the common cache storage, key/value pattern. Ref: PSR-16: Common Interface for Caching Libraries

import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';

@Injectable({providedIn: 'root'})
export class HtmlElementService {

  protected elements: any;
  constructor() {
    this.elements = {};
  }

  public set(key: string, value: HTMLElement): BehaviorSubject {
    if (this.has(key)) {
      this.elements[key].next(value);
    } else {
      this.elements[key] = new BehaviorSubject(value);
    }
    return this.elements[key];
  }

  public get(key: string): BehaviorSubject {
    // We'll always ensure a Subject is returned just incase the HTMLElement hasn't been registered "yet"
    if (!this.has(key)) {
      this.elements[key] = new BehaviorSubject(null);
    }
    return this.elements[key];
  }

  public delete(key: string) {
    if (this.has(key)) {
      this.elements[key].next(null);
      delete this.elements[key];
    }
  }

  public clear() {
    for (const key in Object.keys(this.elements)) {
      if (this.elements.hasOwnProperty(key)) {
        this.elements[key].next(null);
        delete this.elements[key];
      }
    }
    this.elements = {};
  }

  public has(key: string) {
    return (this.elements[key] instanceof BehaviorSubject);
  }

}

This simple and clean service creates a basic cache for HTMLElements which are stored by "key" as an RxJS BehaviorSubject. We could store the HTMLElements directly and return them directly, but due to Angular's natural asynchronous execution (i.e. Views and Code don't always execute along a linear path), we need to wait for the HTML Element to become available and be notified if it is removed or changed.

To use the Html Element Service, you need to register an Element when the owning/parent component completes the View Initialisation:

<main #layout>
   <section>
     <router-outlet></router-outlet>
   </section>
</main>

export class ParentComponent implements OnInit, AfterViewInit, OnDestroy {

  @ViewChild('layout')
  public layoutElement: ElementRef;
  
  constructor(
    protected htmlService: HtmlElementService,
  ) {
	...code.
  }
  
  ...code.
  
  public ngAfterViewInit(): void {
    this.htmlService.set( 'layout', this.layoutElement.nativeElement );
  }

  // Don't forget the include clean-up code within the ngOnDestroy() event.
  public ngOnDestroy() {
	this.htmlService.delete( 'layout' );
  }
}

Within the Child Component, inject the HTML Element service, wait for the component to initialise, then subscribe to the required HTML Element using the "key" specified in the Parent Component.

export class ChildComponent implements OnInit, OnDestroy {

  private layoutSubscription: Subscription;
  private parentScrollEvent: Subscription;
  private layoutElement: HTMLElement;
  
  constructor(
    protected htmlService: HtmlElementService,
  ) {
	...code.
  }
  
  ...code.
  
  public ngOnInit() {
    this.layoutSubscription = this.htmlService.get('layout')
	
         // Get the first non-null value
        .pipe(filter( (elem: HTMLElement) => elem != null))
		
         // Subscribe
        .subscribe((elem: HTMLElement) => {
		
           // Cache the element to use later
           this.layoutElement = elem;
		
           // Or you can attach an event
           if (this.parentScrollEvent instanceof Subscription) {
// This is possibly an update, so clear previous scroll event subscription this.parentScrollEvent.unsubscribe(); } this.parentScrollEvent = fromEvent(elem, 'scroll') .pipe( debounceTime(250), map(event => event.target) ) .subscribe((target: HTMLElement) => { // Lazy-load content once scroll reaches the bottom of the Layout if (target instanceof HTMLElement) { const scrollPosition = target.scrollTop; const scrollHeight = (target.scrollHeight - target.clientHeight); if (scrollPosition >= scrollHeight) { // todo: Load next page of results // } } }); }); } // Again, don't forget the include clean-up code within the ngOnDestroy() event. public ngOnDestroy() { if (this.parentScrollEvent instanceof Subscription) { this.parentScrollEvent.unsubscribe(); } if (this.layoutSubscription instanceof Subscription) { this.layoutSubscription.unsubscribe(); } } }

This is a simple and effective software pattern that can be applicable and useful in numerous circumstances. A manager service, as described here, can also be used to share temporary data between components and other services, or share longer term data stored in cookies and browser local storage.

 


PurcellYoon are a team of expert Angular Developers with a passion for creating exceptional digital experiences. We are committed to delivering superior Angular Applications for all our partners and clients.

We'd love to talk with you about Angular Development.
More questions? Get in touch.