skip to content

Composing Host Behaviors

hostDirectives attaches standalone directives to a component or directive and exposes chosen inputs and outputs under aliases. Interviewers probe run order and why this beats inheritance.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In Angular, what happens when you list a directive in a component's `hostDirectives` array, and can templates bind its inputs by default?

level: juniorimportance: should knowfreq 38%

answer

  1. applied from inside the class
  2. same element, extra instance
  3. the selector no longer matters
  4. public API is opt-in

basics

~20 s

Angular creates an instance of that directive on the component's host element whenever the component is used, and its host bindings and listeners apply there. Its inputs and outputs are not part of the component's API unless listed in the entry.

solid answer

~40 s

`hostDirectives` applies standalone directives to a component's (or directive's) own host element from inside the class, instead of asking every template to add the attribute. Each time the component renders, Angular also instantiates each host directive on the same element, so its host bindings, listeners, lifecycle hooks and injected services all work as if the directive had been matched in a template. The directive's `selector` is ignored for this purpose. By default none of its inputs or outputs are exposed: `<app-menu-item [text]="...">` fails unless the entry is written as `{ directive: Tooltip, inputs: ['text'] }`, optionally with an alias. The list is fixed at compile time, the directives must be standalone (not declared with `standalone: false`) and cannot be components, and the component can `inject()` its host directive instances.

code

ts · 29 lines
ts
import { Component, Directive, inject, input, signal } from '@angular/core';

@Directive({
  host: { '[attr.title]': 'text()' },
})
export class Tooltip {
  readonly text = input('');
}

@Directive({
  host: {
    '[class.focus-ring]': 'visible()',
    '(focus)': 'visible.set(true)',
    '(blur)': 'visible.set(false)',
  },
})
export class FocusRing {
  readonly visible = signal(false);
}

@Component({
  selector: 'app-menu-item',
  template: '<ng-content />',
  host: { 'role': 'menuitem', 'tabindex': '-1' },
  hostDirectives: [{ directive: Tooltip, inputs: ['text'] }, FocusRing],
})
export class MenuItem {
  private readonly focusRing = inject(FocusRing);
}

go deeper

for a junior

Recall that hostDirectives attaches directives to the component's own element and that their inputs and outputs are hidden unless listed.

for a middle

Explain what Angular creates at runtime, why the selector is ignored, and the standalone and compile-time constraints.

for a senior

Use opt-in exposure to keep a component's public API deliberate while composing several behaviours onto one element.

for a principal

Treat host directives as the unit of reusable behaviour in a component library, with each one owning its state and API surface.

## What `hostDirectives` is A **directive** in Angular adds behaviour to an element: classes, attributes, listeners, state. Normally a template applies it by matching its selector, as in `<button appTooltip>`. The **directive composition API**, introduced in Angular 15, lets a component or directive apply directives to its **own host element** from inside its class, through the `hostDirectives` property of `@Component` or `@Directive`. Those directives are called **host directives**. ```ts @Component({ selector: 'app-menu-item', template: '<ng-content />', hostDirectives: [Tooltip, FocusRing], }) export class MenuItem {} ``` Every `<app-menu-item>` now carries a `Tooltip` and a `FocusRing`, without any template writing `appTooltip` or `appFocusRing`. ## What Angular does at runtime When Angular renders `<app-menu-item>`: 1. It creates an instance of each host directive, in addition to the `MenuItem` component instance, all on the same host element. 2. It applies each host directive's **host bindings and listeners** to that element, so the tooltip's `mouseenter` listener and the focus ring's class binding take effect. 3. It runs the host directives' **lifecycle hooks**, just as for directives matched in a template. 4. It makes the instances available to **dependency injection**, so `MenuItem` can `inject(Tooltip)`, and a host directive can inject the component. The host directive's **selector is ignored**: it is applied because the component lists it, not because an attribute matched. A directive meant only for composition can omit the selector entirely. ## Inputs and outputs are opt-in The part candidates most often get wrong: a host directive's inputs and outputs are **not** exposed on the component by default. If `Tooltip` has an input `text`, then `<app-menu-item text="Archive">` does not reach it. To expose it, expand the entry: ```ts hostDirectives: [{ directive: Tooltip, inputs: ['text'], outputs: ['shown'] }] ``` This design keeps the component's **public API deliberate**. Composing a behaviour does not silently widen what consumers can bind, and the component author chooses what becomes part of the contract. ## Constraints | Rule | Consequence | | --- | --- | | Applied statically at compile time | You cannot add or remove host directives at runtime | | Must be standalone | A directive declared with `standalone: false` is rejected | | Cannot be a component | Only directives can be host directives | | Selector is ignored | The directive applies whether or not an attribute is present | | Inputs and outputs are hidden by default | List them in `inputs` and `outputs` to expose them | Because directives are standalone by default since v19, the second rule rarely bites in new code, but it does when composing older NgModule-declared directives. ## Common misconceptions - **"It wraps the component."** Nothing is wrapped and no extra element appears; the directives and the component share one host element. - **"It renders the directive's template."** Directives have no template; host directives contribute host bindings, listeners, state and DI, not markup. - **"Consumers can bind any input of the composed directive."** Only the inputs listed in the entry are reachable from templates. - **"The directive must be written specially for composition."** Any standalone directive works; a selector-less directive is just the most common shape for behaviours meant only to be composed. - **"It costs a wrapper component per behaviour."** Each host directive is one extra class instance on the same element, with no extra DOM. ## A quick mental test To predict what a host directive does, imagine the component's selector element with the directive's attribute added by hand in every template, minus the ability to bind its inputs and outputs unless the component lists them. Everything else, including host bindings, lifecycle hooks and injection, behaves the same way. ## Why it exists Before this API, attaching shared behaviour to many components meant one of three things: asking every consumer to add attributes, a base class the component extends, or duplicating host bindings in each component. `hostDirectives` gives a fourth option: the component itself declares which reusable behaviours it is made of, and each stays an independent, testable directive with its own state.

  • Does a host directive need a selector?
    No. Angular ignores the selector of a directive applied through `hostDirectives`, so a directive written only for composition can leave it out. If it also has a selector, it can still be matched in templates elsewhere, which is how the same behaviour can be both composed and used directly.
  • Can a component read state from its host directives?
    Yes. The component can `inject()` each host directive instance and read its signals or call its public methods, and a host directive can inject the component. Both live on the same element's injector.

saying these in an interview costs you the question

  • A host directive's inputs are automatically available on the component's element.
  • The host directive is only applied if its selector matches the host element.
  • hostDirectives can be changed at runtime to toggle behaviours on and off.
  • A component can be listed in another component's hostDirectives.
  • Host directives render their own template inside the component.
open as a page

In Angular, in what order are host directives and their host component constructed, given inputs and applied host bindings, and what does that order imply?

level: middleimportance: should knowfreq 30%

basics

~20 s

Host directives run before the component or directive that declares them in every phase: they are constructed first, receive inputs and ngOnInit first, and apply host bindings first. The host therefore applies its host bindings last and can override theirs.

open as a page

In Angular, how do you expose and alias a host directive's inputs and outputs on a component, and which mapping mistakes does the compiler reject?

level: middleimportance: should knowfreq 32%

basics

~20 s

Use the object form of a hostDirectives entry and list names in inputs and outputs, writing 'text: hint' to expose Tooltip's text as hint. A required host directive input must be exposed, and names must exist and not clash with another binding.

open as a page

When would you compose Angular behaviours with `hostDirectives` instead of extending a base component or directive class, and how do the two differ?

level: seniorimportance: should knowfreq 35%

basics

~10 s

Compose with hostDirectives to attach several independent behaviours, each with its own instance, state and opt-in API; inherit when a component is a specialised version of one base and should share its whole API.

open as a page

Since Angular 22, what happens when the same directive appears twice in an element's resolved host directive tree, and when does it still fail?

level: seniorimportance: nice to knowfreq 15%

basics

~20 s

Angular 22 de-duplicates it: duplicate host directive matches merge into one instance with combined mappings, and a template selector match beats host directive matches. It fails with NG8024 when merged paths give one binding different aliases.

open as a page