How do I use @ViewChild with an external ng-template (Angular 11)

9.3k views Asked by At

THE PROBLEM

So I have two Angular components, a parent and a child. The parent passes a custom template to the child component, which then hydrates the template with its own data using ngTemplateOutlet.

This works well for the most part. Unfortunately, I run into issues when trying to access the DOM elements of this parent template from the child.

If I try to access <div #container></div> from the default child template using @ViewChild('container',{static: false}), it gets the element without issue. When I do the same using the custom template passed in by app.component, I get the error "cannot read property 'nativeElement' of undefined".

What else do I have to do to access the DOM of my template?

Here's a Stackblitz

App.Component (Parent)

import { Component } from "@angular/core";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent {}
<child [customTemplate]="parentTemplate"></child>

<ng-template #parentTemplate let-context="context">

    <div #container>HELLO FROM CONTAINER</div>
    <button (click)="context.toggleShow()">Toggle Display</button>
    <div *ngIf="context.canShow">Container contains the text: {{context.getContainerText()}}</div>
</ng-template>

child.component (Child)

import {
  Component,
  ElementRef,
  Input,
  TemplateRef,
  ViewChild
} from "@angular/core";

@Component({
  selector: "child",
  templateUrl: "./child.component.html",
  styleUrls: ["./child.component.css"]
})
export class ChildComponent {
  @Input() public customTemplate!: TemplateRef<HTMLElement>;
  @ViewChild("container", { static: false })
  public readonly containerRef!: ElementRef;

  templateContext = { context: this };
  canShow: boolean = false;
  

  toggleShow() {
    this.canShow = !this.canShow;
  }
  getContainerText() {
    return this.containerRef.nativeElement.textContent;
  }
}
<ng-container *ngTemplateOutlet="customTemplate || defaultTemplate; context: templateContext">
</ng-container>

<ng-template #defaultTemplate>
    <div #container>GOODBYE FROM CONTAINER</div>
    <button (click)="toggleShow()">Toggle Display</button>
    <div *ngIf="canShow">Container contains the text: {{getContainerText()}}</div>
</ng-template>

MY QUESTION

How do I use @ViewChild to access this div from an outside template that updates with any changes in the DOM? (Note: Removing the *ngIf is NOT an option for this project)

What's causing this? Are there any lifecycle methods that I can use to remedy this issue?

MY HUNCH I'm guessing that ViewChild is being called BEFORE the DOM updates with its new template and I need to setup a listener for DOM changes. I tried this and failed so I'd really appreciate some wisdom on how best to proceed. Thanks in advance :)

EDIT: This solution needs to properly display <div #container></div> regardless of whether you're passing in a custom template or using the default one.

1

There are 1 answers

1
Drenai On

ViewChild doesn't seem to pick up a rendered template - probably because it's not part of the components template initially. It's not a timing or lifecycle issue, it's just never available as a ViewChild

An approach that does work is to pass in the template as content to the child component, and access it using ContentChildren. You subscribe to the ContentChildren QueryList for changes, which will update when the DOM element becomes rendered

You can then access the nativeElement (the div). If you wanted you could add listeners here to the DOM element, and trigger cd.detectChanges afterwards, but that would be a bit unusual. It would probably be better to handle DOM changes in the parent element, and pass the required values down to the child using regular @Input on the child

@Component({
  selector: "my-app",
  template: `
    <child>
      <ng-template #parentTemplate let-context="context">
        <div #container>Parent Template</div>
      </ng-template>
    </child>
  `,
  styleUrls: ["./app.component.css"]
})
export class AppComponent {}
@Component({
  selector: "child",
  template: `
    <ng-container *ngTemplateOutlet="customTemplate"> </ng-container>
  `,
  styleUrls: ["./child.component.css"]
})
export class ChildComponent implements AfterContentInit {
  @ContentChild("parentTemplate")
  customTemplate: TemplateRef<any>;

  @ContentChildren("container")
  containerList: QueryList<HTMLElement>;

  ngAfterContentInit() {
    this.containerList.changes.subscribe(list => {
      console.log(list.first.nativeElement.innerText);
      // prints 'Parent Template'
    });
  }
}