All Blogs

Inputs Service

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:

@Component({
    selector: "app-title",
    standalone: true,
    template: `
        <h1 style="...">
            <ng-content />
        </h1>
    `,
    host: { style: "display: contents" },
})
export class Title {}

@Component({
    selector: "app-root",
    standalone: true,
    template: `
        <app-title>Inputs Service</app-title>
    `,
    imports: [Title],
})
export class App {}

Using Content Projection

Another example of using Directive to compose UI Elements

@Directive({
    selector: "button[appButton]",
    standalone: true,
    host: { style: "custom button style" },
})
export class Button {}

@Component({
    selector: "app-root",
    standalone: true,
    template: `
        <button appButton>My Button</button>
    `,
    imports: [Button],
})
export 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.

export interface AlertProps {
    severity: "success" | "error" | "warning";
    /* and some other props */
}

export function Alert(props: AlertProps) {
    /* some implementation of an Alert component with different Severities */
    return <div></div>;
}

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.

type AlertPropsWithoutSeverity = Omit<AlertProps, "severity">;

export function SuccessAlert(props: AlertPropsWithoutSeverity) {
    return <Alert severity="success" {...props} />;
}
// 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 of Environment 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 TypeScript private 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.

<ngts-environment-ground
    *ngIf="environmentGround();else noGround"
    [ground]="environmentGround()"
    [map]="environmentMap()"
    [background]="environmentBackground()"
    [frames]="environmentFrames()"
    [near]="environmentNear()"
    [far]="environmentFar()"
    [resolution]="environmentResolution()"
/>
<ng-template #noGround>
    <ngts-environment-map
        *ngIf="environmentMap();else noMap"
        [map]="environmentMap()"
        [background]="environmentBackground()"
        [frames]="environmentFrames()"
        [near]="environmentNear()"
        [far]="environmentFar()"
        [resolution]="environmentResolution()"
    />
    <ng-template #noMap>
        <ngts-environment-cube
            [background]="environmentBackground()"
            [frames]="environmentFrames()"
            [near]="environmentNear()"
            [far]="environmentFar()"
            [resolution]="environmentResolution()"
        />
    </ng-template>
</ng-template>

Let’s start with our solution now.

@Injectable()
export class NgtsEnvironmentInputs {
    readonly #environmentInputs = signal<NgtsEnvironmentInputsState>({});

    readonly ground = computed(() => this.#environmentInputs().ground);
    /* we'll expose a computed for each Input */

    // re-expose SettableSignal#set
    set = this.#environmentInputs.set.bind(this.#environmentInputs);

    /**
     * Abstract SettableSignal#update so we can do
     * this.service.update(partialState);
     * eg: this.service.update({ foo: 'new foo' }); // { foo: 'new foo', bar: 'bar' }
     *     this.service.update({ bar: 'new bar' }); // { foo: 'new foo', bar: 'new bar' }
     */
    update(partial: Partial<NgtsEnvironmentInputsState>) {
        this.#environmentInputs.update((s) => ({ ...s, ...partial }));
    }
}

@Component({
    selector: "ngts-environment",
    standalone: true,
    template: `
        ...
    `,
    providers: [NgtsEnvironmentInputs],
})
export class NgtsEnvironment {
    protected readonly environmentInputs = inject(NgtsEnvironmentInputs);

    @Input() set ground(ground: NgtsEnvironmentInputsState["ground"]) {
        this.environmentInputs.update({ ground });
    }

    @Input() set background(
        background: NgtsEnvironmentInputsState["background"],
    ) {
        this.environmentInputs.update({ background });
    }

    @Input() set map(map: NgtsEnvironmentInputsState["map"]) {
        this.environmentInputs.update({ map });
    }

    @Input() set resolution(
        resolution: NgtsEnvironmentInputsState["resolution"],
    ) {
        this.environmentInputs.update({ resolution });
    }

    @Input() set near(near: NgtsEnvironmentInputsState["near"]) {
        this.environmentInputs.update({ near });
    }

    @Input() set far(far: NgtsEnvironmentInputsState["far"]) {
        this.environmentInputs.update({ far });
    }

    @Input() set frames(frames: NgtsEnvironmentInputsState["frames"]) {
        this.environmentInputs.update({ frames });
    }
}

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:

<ngts-environment-ground *ngIf="environmentInputs.ground();else noGround" />
<ng-template #noGround>
    <ngts-environment-map *ngIf="environmentInputs.map();else noMap" />
    <ng-template #noMap>
        <ngts-environment-cube />
    </ng-template>
</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

@Directive({
    /*...*/
})
export class NgtsEnvironmentMap {
    readonly #environmentInputs = inject(NgtsEnvironmentInputs);

    constructor() {
        this.#environmentInputs.set(defaultForEnvironmentMap);
    }
}

But this would not work because of how Angular resolves Inputs. Here’s the over-simplified timeline:

We can work around this with two steps:

@Injectable()
export class NgtsEnvironmentInputs {
    readonly #environmentInputs = signal<NgtsEnvironmentInputsState>({});

    /* code */

    /**
     * A method to upsert a partial state. If a previous state exists, it will override the partial
     */
    patch(partial: Partial<NgtsEnvironmentInputsState>) {
        this.#environmentInputs.update((previous) => ({
            ...partial,
            ...previous,
        }));
    }
}

Next, we can update NgtsEnvironmentMap

@Directive({
    /*...*/
})
export class NgtsEnvironmentMap {
    readonly #environmentInputs = inject(NgtsEnvironmentInputs);

    @Input() set map(map: NgtsEnvironmentInputsState["map"]) {
        this.#environmentInputs.update({ map });
    }

    constructor() {
        this.#environmentInputs.patch(defaultForEnvironmentMap);
    }
}

Finally, NgtsEnvironment template looks like

<ngts-environment-ground *ngIf="environmentInputs.ground();else noGround" />
<ng-template #noGround>
    <ngts-environment-map
        *ngIf="environmentInputs.map();else noMap"
        [map]="environmentInputs.map()"
    />
    <ng-template #noMap>
        <ngts-environment-cube [background]="environmentInputs.background()" />
    </ng-template>
</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

@Directive()
export abstract class NgtsEnvironmentInputs {
    readonly map = input<NgtsEnvironmentInputsState["map"]>();
    readonly ground = input<NgtsEnvironmentInputsState["ground"]>();
    readonly background = input(false);
    readonly frames = input(Infinity);
    readonly near = input(1);
    readonly far = input(1000);
    readonly resolution = input(256);
}

@Component({
    /* same code */,
    providers: [{ provide: NgtsEnvironmentInputs, useExisting: NgtsEnvironment }]
})
export class NgtsEnvironment extends NgtsEnvironmentInputs {}

Published on Tue May 16 2023


Angular

}