1

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 the NumberComponent and have their @Input()s exposed to NumberComponent's template
  • Enabling service properties to behave as @Input() (either via @Input() or some other modifier/decorator) which are then exposed to NumberComponent's template
  • Some other injectable entity that is not a service, perhaps another application of @Injectable()
nate-kumar
  • 1,675
  • 2
  • 8
  • 15

3 Answers3

1

Ah...the age old question of composition vs. inheritance...

As you probably already know, this has no concrete answer. It will depend on whatever unique situation you find yourself in. Danny Paredes has a great article here lining it all out.

From my personal experience, however, the answer is almost always composition over inheritance -- as it pertains to Angular.

As it pertains to "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?" more specifically, directives are also a great way to go, and the combination of directives and services (segregating functionality to each by their domains of responsibility), along with component composition, is very powerful.

Remember, directives are things that modify the behavior of whatever element or component they are attached to. For example, if you wanted to have a "character count" on every single one of your "field components" (or just normal fields), you could easily create a directive that accomplishes this, and add that directive to each one of your fields -- as opposed to re-implementing the feature in each field component, or implementing the logic behind it in a service, then importing that service in each field component and implementing its API.

jonivrapi
  • 111
  • 2
  • Thanks for your answer! I'm sure this is the right approach but to really solidify the answer would you be able to provide an example implementation of `getNumberValue()` above using a combination of a directive and a component? I can understand applying a directive when the contents of the directive are self-contained, but this is an interesting case where `getNumberValue()` relies on the combination of two `@Input`s. Would be interested to see where you think the logic should sit – nate-kumar Oct 18 '22 at 06:00
  • I can certainly try, but would you mind explaining a little bit more what your requirements for this component(s) are? Only reason i ask is because the specifics really matter as it relates to the implementation, and this particular example is a little bit contrived. The input field is public (and has to be because you are using it in the template), so there is no need to have a getter for that value. Likewise, the disabled property is not a real disabled in the way that it is implemented. But, it can be a real disabled depending on how you want to implement this component – jonivrapi Oct 18 '22 at 13:54
  • A fair question. I agree that the example is contrived, but I was really trying to distil down to the simplest possible representation of the use case; a method that consumes `x` `@Input()`'s, `y` of which make sense to be abstracted out to a base class (or in this revised attempt a directive). In practice, I have encountered this when building a form-field library where a lot of logic could be made common (ControlValueAccessor, basic validation, disabling) and other logic distinct enough to reside on the components. But I feel the question is more fundamental – nate-kumar Oct 18 '22 at 16:08
0

In this case you can take an approach that has no relation with composition nor inheritance:" Create a directive.

Imagine a directive like

@Directive({
  selector: '[isDisabled]'
})
export class IsDisabledDirective {
  @Input()isDisabled:boolean=false;
  @Input() color: any=null;
}

If you inject in constructor of your components the directive (I put as public, you can put as private if not necessary

constructor(@Optional() public isDisabled:IsDisabledDirective ){}

You can use some like

<button [style.background-color]="isDisabled?.color"
        (click)="click()">click</button>

//and
click(){
   console.log(this.isDisabled?
                   this.isDisabled.isDisabled:
                   'no disabled directive')

}

See a stackblitz

Eliseo
  • 50,109
  • 4
  • 29
  • 67
0

Directive composition, coming in Angular 15, may be the solution to the problem: https://medium.com/@henriquecustodia/angular-15-using-the-directive-composition-api-b0246ad0796f

nate-kumar
  • 1,675
  • 2
  • 8
  • 15