Structural Directive Context: What It Is and How It Works

In the previous installment of this series, we explored rendering a template with structural directives and saw that the asterisk syntax boils down to an ng-template behind the scenes. We also relied on dependency injection to grab references to the template and view container, enabling us to insert elements into the DOM.

Up until now, our templates have been purely static — no communication flowed between the template and the rest of the app. Time to fix that by introducing the concept of context in structural directives.

A quick note: this article will stick exclusively with the ng-template notation. The asterisk micro syntax gets its own dedicated discussion in the follow-up post.

Context: Structure and Semantics

We already know that createEmbeddedView on the ViewContainerRef renders our template. This method also accepts an optional second argument, aptly named context. The official docs for ViewContainerRef describe it as follows:
The data-binding context of the embedded view, as declared in the <ng-template> usage. Optional. Default is undefined.

So the context object is our bridge for feeding data into the template. But that’s essentially where the ViewContainerRef documentation stops — there’s no hint about the expected shape of this object or how exactly it should be consumed.

More clarity arrives from an unexpected source: NgTemplateOutlet, another built-in structural directive we haven’t covered yet.

Its purpose is to insert an embedded view from a prepared TemplateRef. In addition to a template reference, it takes a context input. Here’s how the docs describe that input:

A context object to attach to the EmbeddedViewRef. This should be an object, the object's keys will be available for binding by the local template let declarations. Using the key $implicit in the context object will set its value as default

Putting the two documentation sources together, we get the following picture:

  1. createEmbeddedView accepts an optional context parameter.
  2. This object acts as the data source for the embedded view rendered from our ng-template.
  3. Keys on the context object are exposed to the template via let declarations.
  4. One key, $implicit, carries special meaning — it acts as the default binding for let declarations when no explicit key is given.

That clears up a good deal of confusion. Still, what do let declarations look like in practice, and how do they wire the context object to the template markup?

Let Declarations: Connecting Data to the Template

Let declarations are attributes placed on the ng-template element. They expose the directive's context data to the template body.

The general form is:

let-local=“export

  • local — the name you choose to reference inside the template.
  • export — a key from the context object; whatever value that key holds becomes available under the local name. The one exception is $implicit: if you omit the =“export part entirely, the local variable automatically picks up whatever $implicit holds.

Let’s walk through a concrete example to see this binding in action:

  // We create a directive that allows us to provide
  // the template with information on how long a
  // specific unit of measurement is in meters.
  @Directive({
    selector: '[unitsInMeters]',
  })
  export class UnitsInMetersDirective {
  // The context passed to the ng-template
  // it holds information about how long a unit is in meters
  private unitsInMetersContext = {
    // the default is meter
    $implicit: 1,
    // a mile is 1609.34 meters long
    mile: 1609.34,
  };

  // To render our template to the DOM we:
  // get the template ref from the ng-template host
  private template = inject(TemplateRef);
  // get the viewcontainerref from the host: <!--comment-->
  private vcr = inject(ViewContainerRef);

  // on initialization of our directive we
  // render our template to the DOM passing
  // our unitsInMetersContext
  public ngOnInit(): void {
    this.vcr.createEmbeddedView(this.template, this.unitsInMetersContext);
  }
}

Enter fullscreen mode Exit fullscreen mode

Great — now data flows into our templates directly! So far, we’ve passed only constant values, but nothing stops us from binding dynamic data the same way.

Dynamic Context - Pushing the Boundaries of Structural Directives

As directives grow in complexity, the values we pass through context go far beyond simple constants. Angular places no constraints on the types of values you can expose to your template through the context object. Essentially, anything you can store or reference can be made available.

Common examples include:

  • Static values like fixed numbers, strings, or pre-defined objects.
  • Dynamic values that point to properties on the directive itself. Naturally, this includes any @Input() properties.
  • Observables expose streams from within the directive. Although technically just properties, they are notably powerful and help minimize unnecessary template re-renders.
  • Functions that call methods on the directive. A crucial caveat here is that the this binding within those functions must refer to the directive's execution context (not the template context we've been discussing). This is typically handled by wrapping the function in an arrow function or using .bind(this).

Let's put the full potential of context to work by building a directive that handles currency exchange rates - the exchangeRate directive.

Here's what we aim to achieve:

  • Accept user input for the from and to currencies. These will be ISO 3-Letter Currency Codes.
  • Define USD to EUR as the default conversion pair.
  • Fetch live rates from an API.
  • Upon receiving the rate, render the template and expose:
    1. the from currency code
    2. the to currency code
    3. the rate value
    4. the reverseFn function, which swaps the from and to values and recalculates the rate

Ideally, using it in a component would look like this:

@Component({
  selector: 'my-app',
  template: `
  <label>From <input [(ngModel)]="fromInput"> </label>
  <label>To <input [(ngModel)]="toInput"> </label>

  <ng-template exchangeRate [from]="fromInput" [to]="toInput" let-from="from" let-to="to" let-rate="rate" let-reverse="reverseFn">
  <p>Converting from {{from}} to {{to}} the exchange rate is: {{rate}}</p>
  <button (click)="reverse()">Reverse</button>
  </ng-template>
  `,
})
export class AppComponent {
  public fromInput = 'USD';
  public toInput = 'EUR';
}
Enter fullscreen mode Exit fullscreen mode

Let's begin by examining the core structure of the directive:

@Directive({
  selector: '[exchangeRate]',
})
export class ExchangeRateDirective implements OnInit, OnChanges {
  // from input which defaults to USD if none is provided
  @Input('from')
  public from = 'USD';
  // to input which defaults to EUR if none is provided
  @Input('to')
  public to = 'EUR';

  // TemplateRef and ViewContainerRef to render to DOM
  private template = inject(TemplateRef);
  private vcr = inject(ViewContainerRef);
  // HttpClient to query API
  private http = inject(HttpClient);

  // initally we render our template with the default values
  public ngOnInit(): void {
    this.getExchangeRateFromApiCreateContextRenderTemplate();
  }

  // whenever an input value changes we query our
  // api for the new rate and re-render the template
  // given the new input is a 3 letter currency code
  public ngOnChanges(changes: SimpleChanges): void {
    // get the new from value or keep old
    const newFrom = changes.from ? changes.from.currentValue : this.from;
    // get the new to value or keep old
    const newTo = changes.to ? changes.to.currentValue : this.to;
    // over simplified check if inputs are currency code
    if (newFrom.length !== 3 || newTo.length !== 3) {
      // stop processing changes as definitely not a valid currency code
      return;
    }
    // get new rate and render template to DOM
    this.getExchangeRateFromApiCreateContextRenderTemplate();
  }

  private getExchangeRateFromApiCreateContextRenderTemplate(): void {
    ...
  }

  public reverseRate() {
    // this is for demonstration purposes only
    // since from and to are inputs reassigning those inputs
    // might be confusing to the consumer of the directive
    const oldFrom = this.from;
    this.from = this.to;
    this.to = oldFrom;
    this.getExchangeRateFromApiCreateContextRenderTemplate();
  }
}
Enter fullscreen mode Exit fullscreen mode

We start by declaring the from and to inputs, complete with their default values. Next, we inject the necessary services - one to handle rendering our template to the DOM, and another for making the API calls to retrieve the latest rates.

  // from input which defaults to USD if none is provided
  @Input('from')
  public from = 'USD';
  // to input which defaults to EUR if none is provided
  @Input('to')
  public to = 'EUR';

  // TemplateRef and ViewContainerRef to render to DOM
  private template = inject(TemplateRef);
  private vcr = inject(ViewContainerRef);
  // HttpClient to query API
  private http = inject(HttpClient);
Enter fullscreen mode Exit fullscreen mode

During initialization, we fetch the exchange rate, construct the context, and then render the template.

  // initally we render our template with the default values
  public ngOnInit(): void {
    this.getExchangeRateFromApiCreateContextRenderTemplate();
  }
Enter fullscreen mode Exit fullscreen mode

For any subsequent change, we verify that the inputs were modified and that they represent valid currency codes. If neither applies, the logic does nothing. Otherwise, we repeat the process: fetch the rate, build the context, and render the template.

  // whenever an input value changes we query our
  // api for the new rate and re-render the template
  // given the new input is a 3 letter currency code
  public ngOnChanges(changes: SimpleChanges): void {
    // get the new from value or keep old
    const newFrom = changes.from ? changes.from.currentValue : this.from;
    // get the new to value or keep old
    const newTo = changes.to ? changes.to.currentValue : this.to;
    // over simplified check if inputs are currency code
    if (newFrom.length !== 3 || newTo.length !== 3) {
      // stop processing changes as definitely not a valid currency code
      return;
    }
    // get new rate and render template to DOM
    this.getExchangeRateFromApiCreateContextRenderTemplate();
  }
Enter fullscreen mode Exit fullscreen mode

Lastly, we define the reverse method. It swaps the from and to values, requests the new rate, builds the context, and renders the template again.

  public reverseRate() {
    // this is for demonstration purposes only
    // since from and to are inputs reassigning those inputs
    // might be confusing to the consumer of the directive
    const oldFrom = this.from;
    this.from = this.to;
    this.to = oldFrom;
    this.getExchangeRateFromApiCreateContextRenderTemplate();
  }
Enter fullscreen mode Exit fullscreen mode

Let's examine the getExchangeRateFromApiCreateContextRenderTemplate method more closely to understand the full workflow.

  private getExchangeRateFromApiCreateContextRenderTemplate(): void {
    // 1. we get the new rate based on the from and to currencies and re-render our template
    this.http
      .get(`https://open.er-api.com/v6/latest/${this.from}`)
      .pipe(
        // 2. we only care about the immediate response
        take(1),
        // 3. we extract the rate for the currency
        // we convert to
        map((response: ExchangeRateResponse) => {
          return response?.rates?.[this.to] ?? -1;
        })
      )
      .subscribe((rate) => {
        // 4. once the rate arrives, we build the
        // context which will be exposed to our template.
        const exchangeRateContext = {
          // 4.1 current value of our from property
          from: this.from,
          // 4.2 current value of our to property
          to: this.to,
          // 4.3 rate returned by api
          rate,
          // 4.4 function reference to refresh
          reverseFn: () => this.reverseRate(),
        };
        this.vcr.clear();
        // 5. we render the template with the new context
        this.vcr.createEmbeddedView(this.template, exchangeRateContext);
      });
  }
Enter fullscreen mode Exit fullscreen mode
  1. The method calls the HttpClient's get method to fetch a new rate for the from currency, which returns an observable.
  2. The take(1) RxJs operator guarantees we only process the initial emission.
  3. The map operator extracts the rate for our to currency from the API response. If the code isn't found, a placeholder value of -1 is returned. This acts as a signal for consumers of our directive to display an appropriate error message. This is a simplified approach, but it illustrates the core idea.
  4. We subscribe to the observable to get the rate. Once available, the context is assembled with these properties:
    1. from: the directive's current from currency code.
    2. to: the directive's current to currency code.
    3. rate: the rate provided by the API.
    4. reverseFn: a reference to the reverseRate method, bound to the current execution context through an arrow function.
  5. Our template is then rendered to the DOM, accompanied by the newly created context.

Now we can integrate our directive into the AppComponent as illustrated earlier:

@Component({
  selector: 'my-app',
  template: `
  <label>From <input [(ngModel)]="fromInput"> </label>
  <label>To <input [(ngModel)]="toInput"> </label>

  <ng-template exchangeRate [from]="fromInput" [to]="toInput" let-from="from" let-to="to" let-rate="rate" let-reverse="reverseFn">
  <p>Converting from {{from}} to {{to}} the exchange rate is: {{rate}}</p>
  <button (click)="reverse()">Reverse</button>
  </ng-template>
  `,
})
export class AppComponent {
  public fromInput = 'USD';
  public toInput = 'EUR';
}
Enter fullscreen mode Exit fullscreen mode

The result, in action:

Mastering Angular Structural Directives - It’s all about the context — figure 1

Excellent! Our structural directive is now fully interactive, communicating with the rest of the application through inputs and outputs, and even reaching out to the external world by injecting HttpClient and fetching live data from a remote API!

Explore the working directive here

Building Incrementally

Our directive offers plenty of room for enhancement. For example, we could improve performance by leveraging observables for exposed values to prevent unnecessary re-renders. We could also enforce strict type checking for the context within the ng-template.

However, these advanced subjects deserve their own dedicated discussion. If you'd like to dive into strongly typing your template context, I highly recommend this excellent article by Thomas Laforge.

Let's take a moment to appreciate what we've accomplished. We've deepened our understanding of structural directives by unlocking the importance of the context. As you internalize these concepts, get ready for the next challenge: mastering the micro syntax – the magic behind the asterisk.