Understanding the Challenge with HostBinding and Observables
Angular developers frequently encounter a particular limitation when working with reactive streams. The async pipe works seamlessly for binding Observable values directly into templates:
<button [disabled]=”isLoading$ | async”>
However, the same approach fails when attempting to use @HostBinding with observables. While this functionality briefly existed in version 2.1, it was promptly removed:
@Directive({
selector: 'button[my-button]'
host: {
'[disabled]': '(isLoading$ | async)'
}
})
export class MyButtonDirective {
The underlying issue lies in the fact that host bindings belong to the parent view, where pipes might not be accessible. This limitation has generated significant community demand. Let's explore an implementation approach while we await official support.
The Mechanics of Async Binding
Standard bindings operate with concrete values. When working with Observable instances, we need to transition from the reactive paradigm. This involves subscribing to the stream, triggering change detection with each emission, and properly cleaning up subscriptions when no longer required. The async pipe handles all of this internally, and we need to replicate this behavior when binding observables to host elements.
Practical Use Cases for Async Host Binding
Given the reactive nature of Angular development, we frequently encounter scenarios where direct observable binding to host elements would be beneficial:
- Dynamic translation attributes. When implementing runtime language switching, translation services return
Observablestreams. This creates complications when binding to ARIA attributes, title texts, or image alt properties. - Dynamic host classes and styles. Reactive services can control visual properties like transforms or dimensions through style bindings. For example, an
IntersectionObserverservice might apply a class to a sticky header within a directive:

- Property and attribute updates. We might use
BreakpointObserverto adjustplaceholdervalues or a loading service to control button states. - Custom data attributes. In practice, string data stored in
"data-"attributes often originates from reactive services.
Within Taiga UI, the library I contribute to, we've developed utilities to streamline this process declaratively:
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();
}
}
Even with these tools, the boilerplate involved for individual bindings remains substantial. The ideal scenario would be:
@HostBinding('attr.aria-label')
readonly label$ = this.translations.get$('label');
Implementing this natively would require significant Angular framework modifications. However, we can achieve this using only public APIs through an elegant workaround!
Leveraging Event Plugins for This Purpose
While we cannot inject custom logic into host bindings directly, host listeners offer that flexibility. I've previously discussed this approach in an article about enhancing Angular event management with declarative preventDefault and stopPropagation while optimizing change detection. The key insight is that EventManagerPlugins are services that Angular invokes based on event names. Consider this code restructure:
@HostBinding('$.aria-label.attr')
@HostListener('$.aria-label.attr')
readonly label$ = this.translations.get$('label');
Using listeners to solve binding challenges might seem counterintuitive, but stay with me!
We'll employ a
$modifier to distinguish our plugin from standard ones. The.attrsuffix comes at the end rather than the beginning because we want to avoid Angular's regex pattern mistaking it for a string attribute binding.
Event manager plugins receive the HTMLElement, event name, and handler function as parameters. The final argument holds no value for us since Angular wraps it. Therefore, we need to transmit the Observable alongside the element. This is where HostBinding becomes useful — we can bind it to an element property with the same name, making it accessible within our 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();
}
Working Around the Angular Compiler
Let's examine what's happening here. The first line might appear puzzling initially. While arbitrary properties can be attached to DOM elements, Angular validates them:

This validation error may be familiar
A fortuitous aspect of event plugins is that listeners are registered before host bindings execute. This ordering allows our first line to persuade Angular that the property legitimately exists on the element. Secondly, we need to ensure our Observable is available when subscription occurs. As established, it's not yet accessible when our plugin runs. Fortunately, NgZone provides the solution — we can wait for stabilization before retrieving the stream.
NgZonetriggersonStablewhen no pending micro or macro tasks remain. Practically, this means Angular's change detection cycle has completed and all bindings are resolved.
The plugin approach offers another advantage: listener cleanup is handled automatically. Returning a function that terminates our subscription is all that's required!
This technique successfully deceives JIT compilation, but AOT demands more. Runtime property addition works fine, but AOT needs properties declared during compilation. Until this feature request receives attention, we cannot extend Angular's allowed properties list. Consequently, modules containing such bindings must include NO_ERRORS_SCHEMA. Despite its intimidating name, this schema merely disables property existence validation. Note that WebStorm continues to display warnings:

These are non-fatal warnings that don't break the build
AOT also requires host listeners to be invocable functions. We can satisfy this using a utility function that maintains the original type:
function asCallable<T>(a: T): T & Function {
return a as any;
}
The final implementation appears as:
@HostBinding('$.aria-label.attr')
@HostListener('$.aria-label.attr')
readonly label$ = asCallable(this.translations.get$('label'));
An Alternative Approach
There's an alternative strategy that avoids @HostBinding entirely, since we only need to execute once. When the stream comes from dependency injection — a common scenario — factory providers offer a solution. Such a provider can inject ElementRef and establish the 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));
}
Direct constructor assignment is another option:
constructor({nativeElement}: ElementRef) {
nativeElement['$.aria-label.attr'] = this.label$;
}
Then only @HostListener remains necessary, potentially even within the class decorator:
@Directive({
selector: "table[sticky]",
providers: [
IntersectionObserverService,
PROVIDER,
],
host: {
"($.class.stuck)": "stuck$"
}
})
export class StickyDirective {
constructor(@Inject(TOKEN) readonly stuck$: Observable<boolean>) {}
}
The StackBlitz example demonstrates this pattern using IntersectionObserver to apply a shadow effect to a sticky table header:
Executing the Binding
Our earlier code referenced a getMethod call. Beyond properties and attributes, we can also bind to classes and styles, requiring us to handle these cases. Parsing our pseudo event name reveals what action to take 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);
}
The logic remains straightforward. Once implemented, registering the plugin with Angular requires adding it to the global module providers:
{
provide: EVENT_MANAGER_PLUGINS,
useClass: BindEventPlugin,
multi: true,
}
This enhancement substantially reduces boilerplate in our reactive code, eliminating subscription management concerns. This plugin ships with version 2.1.3 of @tinkoff/ng-event-plugins and is also available in @taiga-ui/cdk. The StackBlitz below contains the complete working example for experimentation.
