I occasionally run into the scenario where I have 2+ components which share some functionality, but not all, leading to some duplication among them. Take the following simple example:
@Component()
export class NumberComponent {
@Input() numberValue: number = 1;
@Input() isDisabled: boolean = false;
constructor() {}
getNumberValue(): string | null {
return this.isDisabled ? null : this.numberValue
}
}
@Component()
export class StringComponent {
@Input() stringValue: string = 'a';
@Input() isDisabled: boolean = false;
constructor() {}
getStringValue(): string | null {
return this.isDisabled ? null : this.stringValue
}
}
In this example, there is some commonality between the components that may be abstracted out into a single shared entity, and also some fundamental difference that warrants the components being built independently (as opposed to just having one big component with conditional logic everywhere).
A simple solution to making these components more DRY is to implement inheritance via a base class (or directive as we do not need an additional template)
@Directive()
export abstract class BaseComponent {
@Input() isDisabled: boolean = false;
}
@Component()
export class NumberComponent extends BaseComponent {
@Input() numberValue: number = 1;
constructor() {}
getNumberValue(): string | null {
return this.isDisabled ? null : this.numberValue
}
}
@Component()
export class StringComponent extends BaseComponent {
@Input() stringValue: string = 'a';
constructor() {}
getStringValue(): string | null {
return this.isDisabled ? null : this.stringValue
}
}
But in practice this becomes unwieldly as the components typically have far more @Input()
s and multiple "BaseComponent abstractions" that could be made. Enter composition to the rescue.
The conventional wisdom based on other StackOverflow answers (example) appears to be that composition in Angular is typically achieved via leveraging service classes and dependency injection. Something along the lines of:
@Injectable()
export class BaseService {
isDisabled: boolean = false;
}
@Component()
export class NumberComponent extends BaseComponent {
@Input() numberValue: number = 1;
constructor(private baseService: BaseService) {}
getNumberValue(): string | null {
return this.baseService.isDisabled ? null : this.numberValue
}
}
@Component()
export class StringComponent extends BaseComponent {
@Input() stringValue: string = 'a';
constructor(private baseService: BaseService) {}
getStringValue(): string | null {
return this.baseService.isDisabled ? null : this.stringValue
}
}
This pattern would certainly scale better, allowing "functional groupings" to be separated into individual services and injected in components as required.
However as you can see from the service code, isDisabled
is no longer able to be an @Input()
as a result of being added to the service. This matters because the following, which was possible in the inheritance scenario, is now no longer possible:
<app-number
[numberValue]="2"
[isDisabled]="true">
</app-number>
An alternative would be:
<app-number
[numberValue]="2">
</app-number>
export class ParentComponent {
constructor(private baseService: BaseService) {
this.baseService.isDisabled = true;
}
}
This now means that there is component configuration scattered amongst both the HTML template and the parent component's .ts file., which to me feels like a degradation in DX.
Is there a way to leverage composition in Angular while retaining the ability to provide configuration via the HTML template in the way that @Input()
currently allows? Some avenues for exploration that came to mind (but could be unfeasible/complete nonsense) are:
- Injecting other directives or components (which both natively support
@Input()
) into theNumberComponent
and have their@Input()
s exposed toNumberComponent
's template - Enabling service properties to behave as
@Input()
(either via@Input()
or some other modifier/decorator) which are then exposed toNumberComponent
's template - Some other injectable entity that is not a service, perhaps another application of
@Injectable()