The Basics of Implementing Content Projection in Angular with ng-template

The Basics of Implementing Content Projection in Angular with ng-template

Enhancing component reusability by sharing template references with child components.

In a previous blog post, I showed how to achieve basic content projection in Angular using ng-content. In this article, I'll discuss another method: using a TemplateRef with a component for conditional rendering in more dynamic situations.

What is a TemplateRef?

While <ng-content> works well for straightforward projection scenarios, Angular offers a method to pass templates as component input references using TemplateRef. A TemplateRef is an embedded template that enables the creation of embedded views. The <ng-template> directive not only lets us define an embedded view that we can refer to programmatically but also allows us to dynamically provide context to it. This makes it possible to use this embedded view as an input parameter.

First, we need to understand how using the ng-template directive allows us to define a TemplateRef in our template that we can then reference and render.

<ng-template #exampleTemplate>
  <p>This is an example template. We have only defined it here.</p>
  <p>Simply defining this here will NOT render this template.</p>
  <p>We can access this TemplateRef with the name exampleTemplate</p>
</ng-template>

<!-- Use the ngTemplateOutlet structural directive to render the template -->
<ng-container *ngTemplateOutlet="exampleTemplate"></ng-container>

In the above code block, we define a template using the ng-template directive. The template syntax #exampleTemplate allows us to name this template and later access a reference to it, which is of type TemplateRef.

We then use the ng-container directive along with the ngTemplateOutlet structural directive to render an instance of the template into the DOM. Note that the ng-container directive does not render a DOM element itself, which is why it is typically used with structural directives like *ngTemplateOutlet and *ngIf to add conditional rendering logic inside of Angular views.

Understanding Template Context Data

In the example above, we've only shown how to render static content. However, we can design our templates to accept context data. This data can be injected at the time of rendering through a context parameter.

Context data can be passed either implicitly or explicitly.

Implicit Context Passing

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

@Component({
  selector: 'app-implicit-context-example',
  template: `
    <ng-template #myTemplate let-name>
      Hello, {{ name }}!
    </ng-template>

    <ng-container *ngTemplateOutlet="myTemplate; context: {$implicit: 'John'}"></ng-container>
  `
})
export class ImplicitContextExampleComponent {}

In this implicit example, the context data ('John') is passed without naming it explicitly. The let-name syntax in the ng-template declaration allows us to use the context data ($implicit: 'John') inside the template. The $implicit keyword is used to pass the value directly.

Explicit Context Passing

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

@Component({
  selector: 'app-explicit-context-example',
  template: `
    <ng-template #myTemplate let-greeting="greetingText" let-name="userName">
      {{ greeting }}, {{ name }}!
    </ng-template>

    <ng-container *ngTemplateOutlet="myTemplate; context: {greetingText: 'Hello', userName: 'Jane'}"></ng-container>
  `
})
export class ExplicitContextExampleComponent {}

In the explicit example, the context data is passed with explicit names (greetingText: 'Hello', userName: 'Jane'). In the ng-template, we declare local variables (let-greeting="greetingText" and let-name="userName") that map to the keys in the context object provided to *ngTemplateOutlet. This way, we can access the context data inside the template using the names we've defined.

Example Usage: Providing ng-template as an input

Let's create a simple Angular example to show how to display context-specific user data in a modal by using an ng-template with context. This example will dynamically show the remaining onboarding tasks that the user needs to finish.

Parent Component (parent.component.ts):

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

interface Task {
  description: string;
  completed: boolean;
}

@Component({
  selector: 'app-parent',
  template: `
    <ng-template #userTasksTemplate let-tasks="tasks">
      <div *ngFor="let task of tasks">
        <p *ngIf="!task.completed">{{task.description}}</p>
      </div>
    </ng-template>

    <app-modal *ngIf="tasksRemaining()" [contentTemplate]="userTasksTemplate" [context]="{tasks: userTasks}"></app-modal>
  `
})
export class ParentComponent {
  userTasks: Task[] = [
    { description: 'Verify email', completed: false },
    { description: 'Complete profile', completed: false },
    { description: 'Set up two-factor authentication', completed: false }
  ];

  tasksRemaining(): boolean {
    return this.userTasks.some(task => !task.completed);
  }
}

The ParentComponent creates a list of tasks, each with a description and a completion status. It uses an ng-template to determine how to display these tasks. The component also has logic to show a modal component if there are any incomplete tasks, sending the tasks data to the modal to dynamically show them based on their completion status.

This code illustrates how a parent component can pass a template reference (#userTasksTemplate) along with a context ({tasks: userTasks}) to a child modal component.

Modal Component (modal.component.ts):

import { Component, Input, TemplateRef } from '@angular/core';

@Component({
  selector: 'app-modal',
  template: `
    <div class="modal">
      <ng-container *ngTemplateOutlet="contentTemplate; context: context"></ng-container>
    </div>
  `,
  styles: [`
    .modal {
      border: 1px solid #ccc;
      border-radius: 5px;
      padding: 20px;
      width: 300px;
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background-color: white;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    }
  `]
})
export class ModalComponent {
  @Input() contentTemplate!: TemplateRef<any>;
  @Input() context!: any;
}

The ModalComponent accepts contentTemplate and context as input parameters, which are provided from the ParentComponent usage of the modal.

The modal component then uses ngTemplateOutlet to render the content dynamically, displaying the user's remaining onboarding tasks.

Use Cases for Rendering Dynamic Templates

TemplateRef can be used to instantiate templates based on runtime conditions or data. This is especially helpful when the content needs to dynamically change after the component is initialized.

In order to understand when we might choose to render a template over using <ng-content>, let's run through situations in which you may need more dynamic control over the projection that is rendered.

Here are specific, real-world use cases that show when choosing a TemplateRef input could be more beneficial than using <ng-content>:

1. Customizable Data Grids

Use Case: Implementing a data grid component where each cell's content can vary significantly, from simple text to complex forms or charts based on the data type.

WhyTemplateRef? By accepting TemplateRefs for cell templates, you can provide a high level of customization for each cell type (e.g., currency, dates, dropdowns) while keeping the grid component generic and reusable. Users of the grid component can define how each type of cell should be rendered depending on the data it represents, something that's cumbersome to achieve with <ng-content> due to its static nature.

2. Dynamic Lists with Custom Item Templates

Use Case: Building a list component that needs to display list items differently based on the context, such as a task list where each task type (e.g., meeting, deadline, reminder) has a distinct layout.

WhyTemplateRef? A TemplateRef allows the list component to accept a custom item template for rendering each list item. This method allows for the development of a highly reusable list component that can dynamically adjust its content presentation. It can handle various types of data with complex display logic.

3. Tab Components with Lazy-Loaded Content

Use Case: A tab component where each tab's content is not only different but also might be expensive to initialize (e.g., complex forms, charts, or external data).

WhyTemplateRef? Using TemplateRef inputs for each tab content allows the tab component to instantiate the content only when a tab is activated, potentially improving performance through lazy loading. This is more efficient than <ng-content>, which would render all tabs' content upfront, regardless of whether it's currently visible or not.

4. Tooltip or Modal with Context-Specific Content

Use Case: Tooltips or modals that need to display information specific to the context where they are invoked, such as user details on a dashboard.

WhyTemplateRef? By using a TemplateRef to define the content of a tooltip or modal, you can ensure that the content is both customizable and dynamically loaded based on the specific context (e.g., the user being hovered over). This level of dynamic content injection and context sensitivity is difficult to achieve with static <ng-content> where the content may change depending on user details.

5. Custom Form Field Wrappers

Use Case: Creating a form field wrapper component to standardize the layout, validation messages, and styles of form fields across an application.

WhyTemplateRef? Accepting a TemplateRef for the input field allows the wrapper component to dynamically render different types of inputs (text, select, date picker) with standardized label, error, and hint layouts. It gives you the flexibility to use any form control within the standardized wrapper, a scenario where <ng-content> would be less efficient due to its inability to apply different templates or contexts dynamically.