Part 1: Stop using @ViewChild
Accessing native DOM elements is a fundamental task in web development. Modern frameworks, however, minimize the need for direct DOM access by utilizing various strategies. For example, Angular uses directives like *ngIf
to conditionally add or remove elements and *ngFor
to repeat elements. These techniques abstract away direct DOM manipulation, making it less of a concern when developing applications in Angular or similar frameworks.
Despite these abstractions, there are scenarios where direct DOM access remains necessary. Some third-party libraries, such as Google Maps, require direct DOM elements to function. Google Maps needs a div
element to initialize and display the map. Typically, developers use the @ViewChild
decorator to access the div
element and then provide it to the Map API for initialization.
/// <reference types="@types/google.maps" />
import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core';
@Component({
selector: 'app-map-component',
template: `<div class="map-container" #googleMap></div>`,
styles: ['.map-container {height: 500px;}'],
})
export class MapComponentComponent implements AfterViewInit {
@ViewChild('googleMap', { read: ElementRef }) googleMapElement!: ElementRef<HTMLDivElement>;
map?: google.maps.Map;
async ngAfterViewInit() {
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
// Pass the nativeElement to google map library
this.map = new Map(this.googleMapElement.nativeElement, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
});
}
}
While this approach works, it introduces three significant problems:
- If the
div
element is removed from the template, the code breaks, making the map functionality non-reusable. - The reference to the native element is only available after the
ngAfterViewInit
lifecycle hook. - It violates the Single Responsibility Principle of SOLID design principles. The component using
@ViewChild
becomes responsible for fetching data for the map, initializing it, setting it up, and using it.
There is a better approach: using an attribute directive.
Let’s refactor the previous component and create a map attribute directive.
/// <reference types="@types/google.maps" />
import { Directive, ElementRef, OnInit } from '@angular/core';
@Directive({
selector: 'div[appGoogleMap]'
})
export class GoogleMapDirective implements OnInit {
map?: google.maps.Map;
constructor(private elementRef: ElementRef<HTMLDivElement>) { }
async ngOnInit() {
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
this.map = new Map(this.elementRef.nativeElement, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8,
});
}
}
// Use attribute directive
<div class="map-container" appGoogleMap></div>
Let’s consider another use case: drawing shapes or images on a canvas element. An easy approach is to use @ViewChild
to get the drawing context from the native element.
A better alternative is to use an attribute directive and pass the drawing information via an @Input
property.
import { Component, ElementRef, ViewChild } from '@angular/core';
@Component({
selector: 'app-canvas-component',
template: `<canvas #myCanvas></canvas>`,
})
export class CanvasComponentComponent {
// No way to check if myCanvas on build time
@ViewChild('myCanvas', { read: ElementRef }) canvasRef!: ElementRef<HTMLCanvasElement>;
ngAfterViewInit() {
const canvas = this.canvasRef.nativeElement;
const canvasContext = canvas.getContext('2d');
// Draw using cavas context
canvasContext?.beginPath();
canvasContext?.arc(75, 75, 50, 0, 2 * Math.PI);
canvasContext?.stroke();
}
}
// Vs
import { Directive, ElementRef } from '@angular/core';
@Directive({
// this directive can be used with Cavas element only
selector: 'canvas[appCanvas]'
})
export class CanvasDirective {
constructor(private elementRef: ElementRef<HTMLCanvasElement>) {
this.draw();
}
draw() {
const canvas = this.elementRef.nativeElement;
const canvasContext = canvas.getContext('2d');
// Draw using cavas context
canvasContext?.beginPath();
canvasContext?.arc(75, 75, 50, 0, 2 * Math.PI);
canvasContext?.stroke();
}
}
// Use attribute directive
<canvas appCanvas></canvas>
Looks good, right? But wait, there’s more.
We’ve addressed our first two issues, but our attribute directive is still handling multiple responsibilities. Can we break it down further? Let’s explore that in the next blog.