Making HostBinding work with Observables

We cannot use HostBinding with Observable data — it requires a plain value. This is a pretty sought-after feature. Let’s see how we can implement it while we wait for official support.

Making HostBinding work with Observables

Like many other Angular developers I’ve been dealing with the following limitation. If you want to bind some Observable value to a template, you can use a well-known async pipe:

<button [disabled]=”isLoading$ | async”>

But you cannot use it when doing @HostBinding. It was erroneously possible in version 2.1, but it was quickly removed:

@Directive({
  selector: 'button[my-button]'
  host: {
    '[disabled]': '(isLoading$ | async)'
  }
})
export class MyButtonDirective {

That's because host bindings belong to the parent view and pipe might not be available there. This is a pretty sought after feature. Let’s see how we can implement it while we wait for official support.

How async binding works?

Binding works with plain values. If we have an Observable, this is where we need to leave the reactive world. We need to subscribe to the stream, kick in change detection on every emit and take care of subscription termination when it’s no longer needed. This is what async pipe does for us, in a nutshell, and what we need to do ourselves in case of binding observables to host values.

Why might we need async host binding?

In Angular we work with observables a lot. Most of our services are reactive. Here are a few examples where binding to streams directly would be useful:

  • Translating attributes to a different language. If we want a dynamic language switch in our application — translations must come as Observable. Host binding to ARIA attributes, title or alt for images is currently problematic.
  • Changing host class or style. Observable service can control transform or size through style binding. Or we can have an IntersectionObserver service applying class to sticky header inside a directive:
  • Changing properties/attributes. Sometimes we want to use BreakpointObserver to update placeholder or loading service to disable a button.
  • Arbitrary string data we might store in “data-” attributes. In my practice, sometimes these things also come from Observable services.

In Taiga UI, a library I’m working on, we have a few utils to make this process as declarative as possible:

import {TuiDestroyService, watch} from '@taiga-ui/cdk';
import {Language, TUI_LANGUAGE} from '@taiga-ui/i18n';
import {Observable} from 'rxjs';
import {map, takeUntil} from 'rxjs/operators';

@Component({
   selector: 'my-comp',
   templateUrl: './my-comp.template.html',
   providers: [TuiDestroyService],
})
export class MyComponent {
   @HostBinding('attr.aria-label')
   label = '';

   constructor(
       @Inject(TUI_LANGUAGE) language$: Observable<Language>,
       @Inject(TuiDestroyService) destroy$: Observable<void>,
       @Inject(ChangeDetectorRef) changeDetectorRef: ChangeDetectorRef,
   ) {
       language$.pipe(
           map(getTranslation('label')),
           watch(changeDetectorRef),
           takeUntil(destroy$),
       ).subscribe();
   }
}

Still, that’s a lot of boilerplate code for a single binding. Wouldn’t it be nice if we could just write:

@HostBinding('attr.aria-label')
readonly label$ = this.translations.get$('label');

That would require some serious work on the Angular side of things. But we can do a simple trick, all in the realm of public API to get this done!

Event plugins to the rescue!

We cannot add any custom logic to host binding. But we can do it with host listeners! I have already written an article on this. Read it if you want to learn how to augment your Angular apps with declarative preventDefault/stopPropagation and optimize change detection. The takeaway is — we can add our own services to listen to events called EventManagerPlugins. And they are selected using the name of the event. Let’s say we rewrite our code this way:

@HostBinding('$.aria-label.attr')
@HostListener('$.aria-label.attr')
readonly label$ = this.translations.get$('label');

I know it sounds weird to try to solve binding issues with the listener, but bear with me!

We will add $ modifier to distinguish between plugins. And we will add .attr to the end rather than beginning. That's because we do not want Angular’s regex to match it and think we are binding a simple string attribute.

Event manager plugins have access to HTMLElement, event name and handler function. Last argument is no use for us because it’s a wrapper created by Angular. So we need to somehow pass the Observable with the element. This is where the HostBinding comes in handy. We bind it to the element property of the same name and now we can access it inside the plugin:

addEventListener(element: HTMLElement, event: string): Function {
   element[event] = element[event] ?? EMPTY;

   const method = this.getMethod(element, event);
   const sub = this.manager
       .getZone()
       .onStable.pipe(
           take(1),
           switchMap(() => element[event]),
       )
       .subscribe(method);


   return () => sub.unsubscribe();
}

Dealing with Angular compiler

Let’s investigate the code above. First, the initial line might seem confusing. While we can add arbitrary properties to DOM elements, Angular will validate them.

Can't bind to '$.data-test.attr' since it isn't a known property
You might have seen this before

Cool thing about event plugins is that event listeners are added prior to host binding resolution. So with that first line we trick Angular into believing this property exists on an element. Second, we need to make sure that our Observable is in place when we try to subscribe to it. We just saw that it’s not there yet when we reach our plugin. Thankfully, we have access to NgZone and we can wait for it to stabilize before we try to get the Observable.

NgZone emits onStable when it has no more pending micro and macro tasks. In our case this basically means that Angular’s change detection cycle is finished and all bindings should be resolved.

Another benefit we get from a plugin for free is that it handles listener removal logic. All we need to do is to return a function that will terminate our subscription!

This is enough to trick JIT, but AOT is a little more thorough. We managed to add property during runtime, but AOT wants to know about it during compilation. Until this issue is resolved we cannot add our own list of allowed properties. So we would have to use NO_ERRORS_SCHEMA in a module that has this type of binding. It might sound scary but all it really does is stops checking if an element has a property we are binding to or not. Besides, if you are using WebStorm you would continue to see an error message:

Property "mistake" is not provided by any applicable directives nor by element
It does not fail build though, just warns us

Also, AOT enforces host listeners to be callable. We can imitate it with a simple utility function, preserving original type:

function asCallable<T>(a: T): T & Function {
  return a as any;
}

Final usage would look like the following:

@HostBinding('$.aria-label.attr')
@HostListener('$.aria-label.attr')
readonly label$ = asCallable(this.translations.get$('label'));

A better solution?

Another option is not using @HostBinding at all, since we only need to do it once. If you get your stream from DI, which is often the case, you could create a factory provider. It can inject ElementRef and set this property before returning the stream:

export const TOKEN = new InjectionToken<Observable<boolean>>("");

export const PROVIDER = {
  provide: TOKEN,
  deps: [ElementRef, IntersectionObserverService],
  useFactory: factory,
}

export function factory(
  { nativeElement }: ElementRef,
  entries$: Observable<IntersectionObserverEntry[]>
): Observable<boolean> {
  return nativeElement["$.class.stuck"] = entries$.pipe(map(isIntersecting));
}

Or you can assign the property directly in constructor:

constructor({nativeElement}: ElementRef) { 
  nativeElement['$.aria-label.attr'] = this.label$; 
}

Then you would only need to add @HostListener. You can even do it in the class decorator:

@Directive({
  selector: "table[sticky]",
  providers: [
    IntersectionObserverService,
    PROVIDER,
  ],
  host: {
    "($.class.stuck)": "stuck$"
  }
})
export class StickyDirective {
  constructor(@Inject(TOKEN) readonly stuck$: Observable<boolean>) {}
}

Check out the example above on StackBlitz where IntersectionObserver is used to add shadow to sticky header for the table when it gets stuck:

Actual binding

In the code above we saw a call to getMethod. We can bind not only to properties and attributes, but also to classes and styles — we need to account for it too. That’s why we need to parse our pseudo event name to determine what are we going to do with the value:

private getMethod(element: HTMLElement, event: string): Function {
   const [, key, value, unit = ''] = event.split('.');

   if (event.endsWith('.attr')) {
       return v => v === null
           ? element.removeAttribute(key)
           : element.setAttribute(key, String(v));
   }

   if (key === 'class') {
       return v => element.classList.toggle(value, !!v);
   }

   if (key === 'style') {
       return v => element.style.setProperty(value, `${v}${unit}`);
   }

   return v => (element[key] = v);
}

No complex logic here as well. This is all there is to it. Now we just need to register our plugin with Angular. We do that by adding this to global module providers:

{
  provide: EVENT_MANAGER_PLUGINS,
  useClass: BindEventPlugin,
  multi: true,
}

This little addition can make our code much simpler. We no longer need to worry about subscription logic. This plugin is available in a new version 2.1.3 of our library @tinkoff/ng-event-plugins, as well as @taiga-ui/cdk. You can play with the discussed code on the StackBlitz below. I hope you will find it useful and thanks for reading!