<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Bitovi Blog - UX and UI design, JavaScript and Front-end development
Loading

Angular |

Angular v15 Directive Composition API

Angular v15 Directive Composition API tutorial and code examples. We're showing you how to the use Angular v15 hostDirectives directive.

Eduard Krivanek

Eduard Krivanek

Twitter Reddit

One of the advantages of using the Angular ecosystem is that it never forgets about you. If Angular can’t solve your request right away because the current technology doesn’t allow it, they often return to it. An example of this is issue #8785, which was open for 6 years but was finally solved in 2022.

Angular meme

The Problem with Inheritance

Some developers like to work smarter, not harder. They like to define some logic once, create a function only in one place and reuse it everywhere, following the principle of don’t repeat yourself.

When having multiple Angular elements (Components, Directives, Pipes) that share the same logic, it is common to abstract that logic into a parent class and extend the parent using Inheritance.

@Directive()
export abstract class ParentCalculation {
	calculation1(value: number = 1): number {
		return 1 * value;
	}
	
	// restricted only for Components
	calculation2(value: number = 2): number {
		return 2 * value;
	}
}
// ---------------------------------------
@Component({
	selector: 'app-component1',
	template: `
		<button (click)="calculation1()">calculation1</button>
		<button (click)="calculation2()">calculation2</button>
	`,
})
export class Component1 extends ParentCalculation {
	constructor() {
		super();
	}
}
// ---------------------------------------
@Pipe({
	name: 'app-pipe1',
})
export class Pipe1 extends ParentCalculation implements PipeTransform {
	transform(value: number) {
		return this.calculation1(value);
	}
}

Inheritance works… Until it doesn’t. Let’s demonstrate the following problem: What if the method calculation2 inside the ParentCalculation class would be restricted only to be executed for Components and not for Pipes?

What if many more components would extend the ParentCalculation class, and we realize we will need to add an additional calculation3 method because we, once again, have a repeated logic across multiple places that would be great to abstract?

Abstracting a common logic into the ParentCalculation could be a good idea. Still, we would expose unused methods (calculation2 and calculation3) to every child class (Component, Pipe, Directive) even if they don’t need to or can’t use them.

Enter JavaScript Composition

Composition is a design pattern to implement a has-a relationship. To achieve this, you can use an instance variable that refers to other objects. The main benefits of compositions are:

  • Composition allows changing the behavior at runtime as long as the composed object implements the correct behavior

  • It Decouples the dependency on the parent class, so if the parent behavior changes, it will not break the child component execution.

  • It makes testing less complex because we have to mock Objects, not actual dependency classes

An example of Composition is the following code snippet

interface Human {
name!: string;
}

class Person implements Human {
	name!: string;

	constructor(name: string) {
		this.name = name;
	}
}

class Employee {
	salary!: number;
	human!: Human;

	constructor(h: Human, salary: number) {
		this.human = h;
		this.salary = salary;
	}
}

const person = new Person('Romero');
const employee = new Employee(person, 80000);

Composition API in Angular

Composition sounds interesting in theory, but you might wonder how we can use it in Angular. Let me introduce you to a new composition API directive called hostDirective, available in Angular v15.

Composition allows composing functionalities from multiple standalone Directives into a more complex Directive or Component. Let me demonstrate it in the following snippet.

@Directive({
	selector: '[appClickLogging]',
	standalone: true,
})
export class ButtonDirective {
	@HostBinding('attr.data-button-type')
	@Input()
	type: 'success' | 'error' | 'primary' | 'default' = 'default';

	@HostListener('click', ['$event'])
	onClick() {
		console.log(`%c [Log Base]: logging`, 'color: #3f51b5'););
	}
}

// directive for all primary mat-button
@Directive({
	selector: 'button[color="primary"]',
	standalone: true,
	hostDirectives: [ButtonDirective],
})
export class ButtonPrimaryDirective {
	constructor(@Host() buttonDirective: ButtonDirective) {
		buttonDirective.type = 'primary';
	}
}

The ButtonDirective we created using Angular’s @HostListener('click') is console logging each click event inside the onClick method. It is a standard Angular Directive used in the following approach:

<button appClickLogging type="success">Logging Success button</button>

Let’s consider a scenario where a product manager asks us to track every primary button click.

Instead of manually attaching the appClickLogging to all primary buttons , we can create a ButtonPrimaryDirective with a button[color="primary"] selector to select all <button mat-flat-button color="primary">button</button> buttons. And, using Composition, attach the directive that already implements the logging mechanism inside the hostDirectives .

Attaching the ButtonDirective class into a hostDirectives, we can get a reference to that class inside our child component constructor by using dependency injection like:

constructor(@Host() buttonDirective: ButtonDirective)

Using @Host is optional but helps Angular resolve dependencies. Learn more about resolution modifiers.

Logging steps for some specific button is quite a common use case when we, for example, want to track how the user navigates through our page to find out how our application is being used.

Angular Implementation of hostDirectives

Opening the source of the hostDirectives implementation, we can inspect that we can pass a class, Type<unknown>, into the hostDirectives or an object and specify inputs and outputs properties.

// Code snippet from Angular source
/**
  * Standalone directives that should be applied to the 
  * host whenever the directive is matched.
  * By default none of the inputs or outputs of the 
  * host directives will be available on the host,
  * unless they are specified in the `inputs` or `outputs` properties.
  */
hostDirectives?: (Type<unknown> | {
    directive: Type<unknown>;
    inputs?: string[];
    outputs?: string[];
})[];

Let’s look at an example of how to use inputs and outputs properties for the hostDirectives.

@Directive({
	selector: '[appClickLogging]',
	standalone: true,
})
export class ButtonDirective {
	@Output() clickedEvent = new EventEmitter<void>();
	@Output() mouseEnterEvent = new EventEmitter<void>();

	@Input() loggingName: string = 'Base';

	@HostBinding('attr.data-button-type')
	@Input()
	type: 'success' | 'error' | 'primary' | 'default' = 'default';

	@HostListener('click')
	onClick() {
		this.clickedEvent.emit();
	}

	@HostListener('mouseenter')
	onMouseenter() {
		this.mouseEnterEvent.emit();
	}
}

// ---------------------------------------------
@Directive({
	selector: '[appClickLoggingEnhanced]',
	standalone: true,
	hostDirectives: [
		{
			directive: ButtonDirective,
			inputs: ['loggingName'], // <-- inputs
			outputs: ['mouseEnterEvent'], // <-- outputs
		},
	],
})
export class ButtonDirectiveEnhancedDirective {
	constructor(@Host() buttonDirective: ButtonDirective) {
		buttonDirective.type = 'error';
	}
}

// ---------------------------------------------
@Component({
	selector: 'app-test',
	template: `
		<button appClickLoggingEnhanced 
				(mouseEnterEvent)="onMouseEnterEvent()" 
				[loggingName]="testName">
			Button text
		</button>
	`,
	imports: [ButtonDirectiveEnhancedDirective],
	standalone: true,
})
export class TestComponent {
	testName = 'Test name';

	onMouseEnterEvent(): void {
		console.log('[TestComponent]: mouse enter');
	}
}

What inputs and outputs properties allow is to define which properties from the composite @Directive class are allowed to be exposed by the parent directive.

In our case, ButtonDirective has two inputs and two outputs, and it works as a normal directive. However, we need to tell ButtonDirectiveEnhancedDirectivewhich properties it can expose from the ButtonDirective.

Exposing loggingName from ButtonDirectiveEnhancedDirective allows defining the @Input() in TestComponent. If the loggingName was removed from inputs in ButtonDirectiveEnhancedDirective, the compiler would throw the following error.

screenshot showing Angular 15 code

Angular Composition API restrictions

Remember the following.

  • You can use hostDirectives in both Components and Directives

  • Classes in hostDirectives must be decorated with @Directive decorator

  • The hostDirectives can be used only for standalone components

  • Use inputs and outputs to expose properties from the composed hostDirectives

  • Accessing the composed directive instance inside the constructor, like constructor(buttonDirective: ButtonDirective), without including it into hostDirectives, would result in a error message No provider for ButtonDirective found in NodeInjector

Check out the proposed PR and the massive code implementation to find out more about implementing the hostDirectives support for the Angular compiler.

Composition API Examples

Using Composition in Angular instead of Inheritance may be a little bit difficult to wrap your head around. You may not instantly come up with use cases for how to use the new hostDirectives directive, so let me demonstrate its purpose in the following examples.

Example 1

We have created a TypingTrackingDirective directive, that by attaching it to any input element will log out the user’s input. We are asked to add this implementation to all textarea inputs

@Directive({
	selector: 'appTypingTracking',
	standalone: true,
})
export class TypingTrackingDirective {
	@HostListener('focus', ['$event.target.value'])
	onFocus(value: string) {
		console.log(value);
	}

	@HostListener('input', ['$event.target.value'])
	onInput(value: string) {
		console.log(`%c Tracking input: ${value}`, 'color: #91ea45');
	}
}

@Directive({
	selector: 'textarea',
	standalone: true,
	hostDirectives: [TypingTrackingDirective],
})
export class InputTrackingDirective {}

 

Example 2

A common practice is to use takeUntil RxJS operator with some Subject when subscribing to an Observable. This logic is repeated in multiple places, so let’s abstract it.

// Credits: https://twitter.com/_crisbeto/status/1582475442715385858

@Directive({
	standalone: true,
})
export class DestroyDirective implements OnDestroy {
	private destroy$ = new Subject<void>();

	get pipe() {
		return pipe(takeUntil(this.destroy$));
	}

	ngOnDestroy(): void {
		this.destroy$.next();
		this.destroy$.complete();
	}
}

@Component({
	selector: 'app-test',
	template: '...',
	standalone: true,
	providers: [ApiService, DestroyDirective],
})
export class CompositionNewComponent {
	private untilDestroyed = inject(DestroyDirective).pipe;

	constructor(private apiService: ApiService) {
		this.apiService.linstenToUserWS()
			.pipe(this.untilDestroyed))
			.subscribe(res => console.log(res))
	}
}

 

Example 3

We want to extend the behavior of the *ngIf directive so that we are able to reveal HTML content when an HTTP request has finished, but we can also toggle its visibility by clicking on a button.

@Injectable()
export class ActiveService {
	private active$ = new BehaviorSubject<boolean>(false);

	isActive(): Observable<boolean> {
		return this.active$.asObservable();
	}

	toggleActivation(): void {
		console.log('Toggling state');
		this.active$.next(!this.active$.value);
	}
}

@Directive({
	selector: '[ifActive]',
	standalone: true,
	hostDirectives: [NgIf],
})
export class ActiveDirective implements OnInit {
	private ngIfDirective = inject(NgIf);
	private activeService = inject(ActiveService);

	ngOnInit(): void {
		this.activeService.isActive().subscribe((state) => {
			this.ngIfDirective.ngIf = state;
		});
	}
}

@Directive({
	selector: '[toggleActive]',
	standalone: true,
})
export class ToggleActiveDirective {
	constructor(@SkipSelf() private activeService: ActiveService) {}

	@HostListener('click', ['$event'])
	onClick() {
		this.activeService.toggleActivation();
	}
}

Component({
	selector: 'app-test',
	template: `
    <!-- using ngIfActive directive -->
    <button toggleActive>Toggle Reveal</button>

    <!-- display 2 revealing block -->
    <div *ifActive>Toggling state 1111</div>
    <div *ifActive>Toggling state 2222</div>
  `,
	imports: [ToggleActiveDirective, ActiveDirective],
	standalone: true,
	providers: [ActiveService],
});
export class TestComponent {
	constructor(@Host() private activeService: ActiveService) {
		// mocking HTTP request -> reveal content after 3 seconds
		of([])
			.pipe(delay(3000))
			.subscribe(() => this.activeService.toggleActivation());
	}
}

Example 4

Let’s say we are using CdkDrag and matTooltip plenty of times in our application by the following approach

<button cdkDrag matTooltip="Info" [matTooltipHideDelay]="400">
    Click me
</button>

To compose the behavior of these two Directives, you may be tempted to create something like

@Directive({
	selector: '[appButtonTooltip]',
	standalone: true,
	hostDirectives: [
		{
			directive: MatTooltip,
			inputs: ['matTooltip: tooltip','matTooltipHideDelay: tooltipHideDelay'],
		},
		{
			directive: CdkDrag,		
		},
	],
})
export class ButtonTooltipDirective {}

Unfortunately, this will not work. The Angular compiler will throw an error because as we previously mentioned you can only use Directives in hostDirectives in Standalone Components.

Until Angular Material Directives do not migrate to standalone Directives, you have to use the old approach: <button cdkDrag matTooltip="Info" [matTooltipHideDelay]="400">.

Summary

It is good to see that the Angular team is focusing on expanding its ecosystem and bringing new features. In this article, we described the newly added hostDirectives directive, and how it can be useful for abstracting repeated logic into one place and avoiding using Inheritance. The source code that covers this example and additional features can be found in the following GitHub repository.

For more information check out our training program!

Waiting for Angular to fix your problem?

Let us take a whack at it! Book your free Angular consulting call to talk through your project with an expert.