I recently shared my thoughts on Twitter about how Angular is missing Props Spreading, resulting in a subpar experience with Component Composition. Let’s take a moment to explore what Component Composition means in this context.
What is Composition?
Composition, particularly Component Composition, is a pattern of composing different UI Elements to build other UI Elements. In Angular, there are various ways to utilize Component Composition: Content Projection, Directives, Template Ref, and Services
Each provides different solutions to different use cases. Here’s an example:
1@Component({2 selector: "app-title",3 standalone: true,4 template: `5 <h1 style="...">6 <ng-content />7 </h1>8 `,9 host: { style: "display: contents" },10})11export class Title {}12
13@Component({14 selector: "app-root",15 standalone: true,16 template: `17 <app-title>Inputs Service</app-title>18 `,19 imports: [Title],20})21export class App {}
Using Content Projection
Another example of using Directive to compose UI Elements
1@Directive({2 selector: "button[appButton]",3 standalone: true,4 host: { style: "custom button style" },5})6export class Button {}7
8@Component({9 selector: "app-root",10 standalone: true,11 template: `12 <button appButton>My Button</button>13 `,14 imports: [Button],15})16export class App {}
Using Directive
Numerous use cases and online resources offer detailed explanations of these methods. Therefore, exploring those resources for an in-depth understanding of Component Composition is advisable.
What is Props Spreading?
Props Spreading is a technique in JSX-based technologies like React or SolidJS. Props Spreading is not an enabler of Component Composition, but it makes it much easier and more strongly typed.
1export interface AlertProps {2 severity: "success" | "error" | "warning";3 /* and some other props */4}5
6export function Alert(props: AlertProps) {7 /* some implementation of an Alert component with different Severities */8 return <div></div>;9}
A pseudo-example of an Alert
component with different
severity
levels
Alert
is acceptable to use on its own. However, we can provide some abstractions to make it easier to use. For example, we can create SuccessAlert
, ErrorAlert
, and WarningAlert
so the consumers do not have to keep providing severity
prop for Alert
component.
1type AlertPropsWithoutSeverity = Omit<AlertProps, "severity">;2
3export function SuccessAlert(props: AlertPropsWithoutSeverity) {4 return <Alert severity="success" {...props} />;5}6// ErrorAlert and WarningAlert are similar
SuccessAlert
is now easier to use and maintains type definitions
for every other prop than severity
Problem
Back to the tweet, the context here is that I have a NgtsEnvironment
component. This component determines which type of components to render based on some inputs. There are NgtsEnvironmentCube
, NgtsEnvironmentMap
, NgtsEnvironmentGround
, and NgtsEnvironmentPortal
.
It is troublesome for consumers to figure out which one they need to remember to use. Hence, NgtsEnvironment
helps abstract this decision away. The problem is NgtsEnvironment
has the same set of Inputs with all four types. Without something like Props Spreading, this results in many duplications in Component code and on the Template.
Disclaimer:
NgtsEnvironment
is a port ofEnvironment
from React Three Fiber ecosystem. It is already a lot of work to port these components from React over to Angular, so I did not spend additional time attempting to re-design these components to fit Angular’s mental model better. Thus, the complaint 😅
What can we do? Are we completely stuck without some form of Props Spreading? Nope, we can do better.
Inputs Service
The solution here is not entirely on-par with Props Spreading, but it alleviates some pain I am running into. And I want to share it with the world.
Disclaimer: We’ll be using Signals. We’ll use
#
private instead of TypeScriptprivate
keyword. We can debate either in real-world situations because#
makes Debugging a little trickier.
As mentioned above, Service is also a means to achieve Component Composition in Angular. Instead of duplicating the Inputs on all components, we can have the NgtsEnvironment
declare the Inputs and a Service to store those Inputs’ values. The other components (NgtsEnvironment***
) can inject the Service to read those Inputs.
Before we start, we will shorten our Inputs to 5-6 and render 3 components: NgtsEnvironmentGround
, NgtsEnvironmentCube
, and NgtsEnvironmentMap
for brevity. Let me remind everyone what NgtsEnvironment
template would look like with 3 Inputs and 3 component types.
1<ngts-environment-ground2 *ngIf="environmentGround();else noGround"3 [ground]="environmentGround()"4 [map]="environmentMap()"5 [background]="environmentBackground()"6 [frames]="environmentFrames()"7 [near]="environmentNear()"8 [far]="environmentFar()"9 [resolution]="environmentResolution()"10/>11<ng-template #noGround>12 <ngts-environment-map13 *ngIf="environmentMap();else noMap"14 [map]="environmentMap()"15 [background]="environmentBackground()"16 [frames]="environmentFrames()"17 [near]="environmentNear()"18 [far]="environmentFar()"19 [resolution]="environmentResolution()"20 />21 <ng-template #noMap>22 <ngts-environment-cube23 [background]="environmentBackground()"24 [frames]="environmentFrames()"25 [near]="environmentNear()"26 [far]="environmentFar()"27 [resolution]="environmentResolution()"28 />29 </ng-template>30</ng-template>
Let’s start with our solution now.
1@Injectable()2export class NgtsEnvironmentInputs {3 readonly #environmentInputs = signal<NgtsEnvironmentInputsState>({});4
5 readonly ground = computed(() => this.#environmentInputs().ground);6 /* we'll expose a computed for each Input */7
8 // re-expose SettableSignal#set9 set = this.#environmentInputs.set.bind(this.#environmentInputs);10
11 /**12 * Abstract SettableSignal#update so we can do13 * this.service.update(partialState);14 * eg: this.service.update({ foo: 'new foo' }); // { foo: 'new foo', bar: 'bar' }15 * this.service.update({ bar: 'new bar' }); // { foo: 'new foo', bar: 'new bar' }16 */17 update(partial: Partial<NgtsEnvironmentInputsState>) {18 this.#environmentInputs.update((s) => ({ ...s, ...partial }));19 }20}21
22@Component({23 selector: "ngts-environment",24 standalone: true,25 template: `26 ...27 `,28 providers: [NgtsEnvironmentInputs],29})30export class NgtsEnvironment {31 protected readonly environmentInputs = inject(NgtsEnvironmentInputs);32
33 @Input() set ground(ground: NgtsEnvironmentInputsState["ground"]) {34 this.environmentInputs.update({ ground });35 }36
37 @Input() set background(38 background: NgtsEnvironmentInputsState["background"],39 ) {40 this.environmentInputs.update({ background });41 }42
43 @Input() set map(map: NgtsEnvironmentInputsState["map"]) {44 this.environmentInputs.update({ map });45 }46
47 @Input() set resolution(48 resolution: NgtsEnvironmentInputsState["resolution"],49 ) {50 this.environmentInputs.update({ resolution });51 }52
53 @Input() set near(near: NgtsEnvironmentInputsState["near"]) {54 this.environmentInputs.update({ near });55 }56
57 @Input() set far(far: NgtsEnvironmentInputsState["far"]) {58 this.environmentInputs.update({ far });59 }60
61 @Input() set frames(frames: NgtsEnvironmentInputsState["frames"]) {62 this.environmentInputs.update({ frames });63 }64}
Here, we set up a NgtsEnvironmentInputs
Service to store the Inputs’ values. Naturally, we can use any data structure to store the values. In this example, we use Angular Signals.
We provide NgtsEnvironmentInputs
service on NgtsEnvironment
providers, so for every new instance of NgtsEnvironment
, we’ll have a new instance of NgtsEnvironmentInputs
. Instead of duplicating the Inputs on NgtsEnvironmentGround
, NgtsEnvironmentMap
, and NgtsEnvironmentCube
, these components can inject NgtsEnvironmentInputs
and use the computed
values. With this change, NgtsEnvironment
template now looks like the following:
1<ngts-environment-ground *ngIf="environmentInputs.ground();else noGround" />2<ng-template #noGround>3 <ngts-environment-map *ngIf="environmentInputs.map();else noMap" />4 <ng-template #noMap>5 <ngts-environment-cube />6 </ng-template>7</ng-template>
It is a lot cleaner! However, we do have a problem which is the default values for the Inputs. Each component can provide different default values for NgtsEnvironmentInputsState
.
At first glance, we might be thinking of doing this
1@Directive({2 /*...*/3})4export class NgtsEnvironmentMap {5 readonly #environmentInputs = inject(NgtsEnvironmentInputs);6
7 constructor() {8 this.#environmentInputs.set(defaultForEnvironmentMap);9 }10}
But this would not work because of how Angular resolves Inputs. Here’s the over-simplified timeline:
<ngts-environment [map]="mapFromConsumer()" />
is used by the consumers and they provide some InputsNgtsEnvironment
is instantiated along withNgtsEnvironmentInputs
NgtsEnvironmentInputs
storesmap
value from@Input() set map()
NgtsEnvironment
renders<ngts-environment-map [map]="environmentInputs.map()" />
NgtsEnvironmentMap
is instantiated, and itsconstructor
runs. Here,NgtsEnvironmentInputs
updatesmap
withdefaultForEnvironmentMap
, which is problematic.
We can work around this with two steps:
- Duplicate
map
Input onNgtsEnvironmentMap
(similarly, we’ll need to duplicatebackground
Input onNgtsEnvironmentCube
)Which Input needs to be duplicated is varied on a case-by-case basis.
- Implement a
patch
method in ourNgtsEnvironmentInputs
service
1@Injectable()2export class NgtsEnvironmentInputs {3 readonly #environmentInputs = signal<NgtsEnvironmentInputsState>({});4
5 /* code */6
7 /**8 * A method to upsert a partial state. If a previous state exists, it will override the partial9 */10 patch(partial: Partial<NgtsEnvironmentInputsState>) {11 this.#environmentInputs.update((previous) => ({12 ...partial,13 ...previous,14 }));15 }16}
Next, we can update NgtsEnvironmentMap
1@Directive({2 /*...*/3})4export class NgtsEnvironmentMap {5 readonly #environmentInputs = inject(NgtsEnvironmentInputs);6
7 @Input() set map(map: NgtsEnvironmentInputsState["map"]) {8 this.#environmentInputs.update({ map });9 }10
11 constructor() {12 this.#environmentInputs.patch(defaultForEnvironmentMap);13 }14}
Finally, NgtsEnvironment
template looks like
1<ngts-environment-ground *ngIf="environmentInputs.ground();else noGround" />2<ng-template #noGround>3 <ngts-environment-map4 *ngIf="environmentInputs.map();else noMap"5 [map]="environmentInputs.map()"6 />7 <ng-template #noMap>8 <ngts-environment-cube [background]="environmentInputs.background()" />9 </ng-template>10</ng-template>
Conclusion
Inputs Service is a pattern that can help with Component Composition when duplicating Inputs exist. It is a lot of code than Props Spreading, but it does get the job done. In addition, Inputs Service can contain encapsulated logic related to the Components at hand instead of just storing the Inputs values. I hope this blog post has been helpful to you. See you in the next one.
(Bonus) Signal Inputs
In the near future, we will have Signals Inputs, which will change our approach a bit. Instead of a Service, we would have an Abstract Directive
1@Directive()2export abstract class NgtsEnvironmentInputs {3 readonly map = input<NgtsEnvironmentInputsState["map"]>();4 readonly ground = input<NgtsEnvironmentInputsState["ground"]>();5 readonly background = input(false);6 readonly frames = input(Infinity);7 readonly near = input(1);8 readonly far = input(1000);9 readonly resolution = input(256);10}11
12@Component({13 /* same code */,14 providers: [{ provide: NgtsEnvironmentInputs, useExisting: NgtsEnvironment }]15})16export class NgtsEnvironment extends NgtsEnvironmentInputs {}