ben tedder : code things

Angular 4 templates - passing methods with context using [ngTemplateOutlet] and [ngTemplateOutletContext]

Time for an epic Angular 4 blog post.

I'm creating a component (a data grid) that is supposed to have no knowledge of the things inside it. Yet the components inside should be able to call methods on the data grid component (ie, to highlight the row the component is in). In order to do this, I had the following:

<!-- data-grid.component.html -->
<ng-template *ngFor="col of cols"
             [ngTemplateOutlet]="myTemplates[col.templateName]"
             [ngTemplateOutletContext]="{
                highlight: highlight,
                highlighted: highlighted
              }"></ng-template>

// data-grid.component.ts (simplified)
export class DataGridComponent {
  highlighted = false;
  highlight() {
    this.highlighted = true;
  }
}

Now in my app I would do something like this:

<!-- other-page.html -->
<my-data-grid>
  <ng-template templateName="colName"
               let-highlight="highlight"
               let-highlighted="highlighted">
    {{highlighted}}
    <button onClick="highlight()">turn on highlighting</button>
  </ng-template>
</my-data-grid>

I'll explain what's going on here. First, it's all linked up via the templateName="colName" (that's for another blog post). But the let keywords are passing in the context of the object keys that I passed in using the ngTemplateOutletContext in the component above. This means the component now has access to the highlight method as well as the highlighted boolean.

The goal is to have the embedded component do its own thing, while still have access to the component it's rendered in.

The problem with this is that when you click the turn on highlighting button, nothing happens.

First solution

The reason the method was being called was that it had no context for this, so it couldn't set highlighted of undefined. So passing in a basic .bind(this) solved the problem...

<!-- data-grid.component.html -->
<ng-template *ngFor="col of cols"
             [ngTemplateOutlet]="myTemplates[col.templateName]"
             [ngTemplateOutletContext]="{
                highlight: highlight.bind(this),
                highlighted: highlighted
              }">

Next problem

Passing .bind(this) created more problems than it solved. My next step was to take the basic nested html and turn them into an actual component:

<!-- other-page.html -->
<my-data-grid>
  <ng-template templateName="colName"
               let-highlight="highlight"
               let-highlighted="highlighted">
    <my-nested-component [highlighted]="highlighted"
                         (highlight)="highlight()">
    </my-nested-component>
  </ng-template>
</my-data-grid>

I will spare you the code of the my-nested-component, because what was happening is that it was getting constructed {n2} times. And then whenever clicking on the <button> element inside of the component it was not only not firing the highlight function, but it was causing every instance of the *my-nested-component** to re-initialize. Driving me crazy.

So with the help of a co-worker we discovered that it was the .bind(this) that was causing the crazy numbers of re-initializing and constructing.

But now we're left with the problem we started with, of not being able to trigger the callback from within the nested component.

Second solution

Instead of passing down highlight (and in my case 4 other methods I wanted to bind) I decided to just pass down an eventemitter:

// data-grid.component.ts (simplified)
export class DataGridComponent implements OnInit {
  highlighted = false;
  rowActionEmitter = new EventEmitter<string>();
  ngOnInit(): void {
    this.rowActionEmitter.subscribe(res => {
      switch (res) {
        case 'highlight':
          this.highlight();
          break;
        case 'lock':
          this.lock();
          break;
        default:
          break;
      }
    })
  }
  highlight() {
    this.highlighted = true;
  }
}

Now in my template I have this:

<!-- data-grid.component.html -->
<ng-template *ngFor="col of cols"
             [ngTemplateOutlet]="myTemplates[col.templateName]"
             [ngTemplateOutletContext]="{
                rowActionEmitter: rowActionEmitter,
                highlighted: highlighted
              }">

Now in my nested component I can do this:

<!-- other-page.html -->
<my-data-grid>
  <ng-template templateName="colName"
               let-rowActionEmitter="rowActionEmitter"
               let-highlighted="highlighted">
    <my-nested-component [highlighted]="highlighted"
                         (onLock)="rowActionEmitter.emit('lock')"
                         (onHighlight)="rowActionEmitter.emit('highlight')">
    </my-nested-component>
  </ng-template>
</my-data-grid>

So, I call this the "second" solution and not my complete solution. I like this solution a lot better than the first one though, because there's one thing I'm doing, and I'm using an observable to emit things back up instead of callbacks. But I don't know if this is a true Angular 4 way of doing things.

Thoughts/comments? For now I'm mostly happy to just have a solution!

Other searches I tried to find this solution:

  • Pass context with callback using .bind(this)
  • Pass a method to ng-template context
  • ngoutletcontext bind(this)
  • ngOutletContext two-way binding
  • templateRef with nested component
  • Nested templates with ngTemplateOutlet, ngOutletContext