Eduard Krivanek
Posted on November 17, 2022
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.
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' | 'primary' | 'default' = 'default';
@HostListener('click', ['$event'])
onClick() {
console.log(`%c [Log]: ${this.type}`, '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' | '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 = 'success';
}
}
// ---------------------------------------------
@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 ButtonDirectiveEnhancedDirective
which 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.
Angular Composition API restrictions
NOTE: Remember the following.
- You can use
hostDirectives
in both Components and Directives- Classes in
hostDirectives
must be decorated with@Directive
decorator- The
hostDirectives
directive can be used only for standalone components- Use
inputs
andoutputs
to expose properties from the composedhostDirectives
- Accessing the composed directive instance inside the
constructor
, likeconstructor(buttonDirective: ButtonDirective)
, without including it intohostDirectives
, would result in an 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(`Tracking input: ${value}`);
}
}
@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.
Posted on November 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 30, 2024
October 29, 2024