Angular @ViewChild with DOM Elements and Asynchronous Data

While working in Angular, it is safe to assume that most important pieces of data to be displayed would be loaded asynchronously via APIs. While most of this data will be displayed in structured/reusable components, there might be some niche use cases where you want to set the value of a simple span/text element with a single piece of data returned from the API or broadly, with a piece of data that is loaded asynchronously (For example, think about typical /auth APIs that return username along with other decoded pieces of info from the user JWT after successful authentication and you want to display the username in the top navbar).

To programmatically access a DOM element, Angular provides us with the @ViewChild directive. It can be used with other things like Components, Services and other directives. However, in this post, I am going to specifically talk about using @ViewChild directive with DOM elements to access them programmatically from the component TypeScript code.

Problem

Have a look at the example component below:

import { Component, ElementRef, ViewChild } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { importProvidersFrom } from '@angular/core';
import 'zone.js';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <div *ngIf="isDataLoaded">
      <span #title>
      </span>
    </div>
  `,
  imports: [CommonModule],
})
export class App {
  isDataLoaded = false;
  selectedProduct: string = '';

  @ViewChild('title') title!: ElementRef;

  constructor(private httpClient: HttpClient) {}

  ngOnInit() {
    this.httpClient
      .get('https://dummyjson.com/products')
      .subscribe((data: any) => {
        if (data) {
          this.isDataLoaded = true;
          this.title.nativeElement.innerHTML =
            '<h1>First Product: ' + data.products[0].title + '</h1>';
        }
      });
  }
}

bootstrapApplication(App, {
  providers: [importProvidersFrom(HttpClientModule)],
});

The above component is a simple demonstration wherein we are trying to show the title of a sample product loaded from this dummy API: https://dummyjson.com/products in the <span> tag marked with “#title” identifier.

Running the above component will give you the following error in the console:

ERROR Error: Cannot read properties of undefined (reading 'nativeElement')

Debugging

What’s happening is since the container <div> tag is annotated with *ngIf directive, the element does not get added to the DOM tree till the condition within the *ngIf directive evaluates to true (in our case, until isDataLoaded flag is set to true). At the same time, the this.title class member within the component, annotated with the @ViewChild directive, gets set to undefined since browser did not find any such element (marked with #title) in the DOM tree. When the API does finally return the response and our subscription function fires, isDataLoaded is set to true, the container <div> and child <span> element get added to the DOM tree but the @ViewChild directive does not search the tree again for the element. Thus, setting the native element’s inner HTML within the API subscription indicates that the this.title is undefined.

Solution

This basically means that @ViewChild elements are not allowed to be annotated with *ngIf directives or shouldn’t be placed within containers annotated with *ngIf directive. The solution here is to use HTML’s hidden attribute to control when the element is to be shown instead of *ngIf (with the default value being true so that the piece of information stays hidden until data is loaded). This way, we make sure @ViewChild is able to find the element in the DOM tree on load and changing it’s HTML attribute as well as changing it's innerHTML programmatically won’t cause the undefined error we saw above.

Working Code

import { Component, ElementRef, ViewChild } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { importProvidersFrom } from '@angular/core';
import 'zone.js';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <div>
      <span hidden=true #title>
      </span>
    </div>
  `,
  imports: [CommonModule],
})
export class App {
  selectedProduct: string = '';

  @ViewChild('title') title!: ElementRef;

  constructor(private httpClient: HttpClient) {}

  ngOnInit() {
    this.httpClient
      .get('https://dummyjson.com/products')
      .subscribe((data: any) => {
        if (data) {
          this.title.nativeElement.innerHTML =
            '<h1>First Product: ' + data.products[0].title + '</h1>';
          // SET hidden TO false TO ALLOW THE ELEMENT TO BE DISPLAYED
          this.title.nativeElement.hidden = false;
        }
      });
  }
}

bootstrapApplication(App, {
  providers: [importProvidersFrom(HttpClientModule)],
});

Recap of Changes

  1. We remove the *ngIf on the container <div> element.

  2. We set hidden attribute to true for the target <span> element.

  3. In the subscription for our target piece of data, we change the hidden attribute on the <span> element via the @ViewChild directive that points to it.

Did you find this article valuable?

Support Jimil Shah by becoming a sponsor. Any amount is appreciated!