Home > Blockchain >  Angular composition vs inheritance - with @Inputs
Angular composition vs inheritance - with @Inputs

Time:10-18

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()

CodePudding user response:

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.

CodePudding user response:

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

  • Related