Angular @ViewChild with DOM Elements and Asynchronous Data
Table of contents
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:
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
We remove the
*ngIf
on the container<div>
element.We set
hidden
attribute totrue
for the target<span>
element.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.