All Blogs

Angular Object Inputs

In this blog post, we will discuss how to correctly provide object as inputs in Angular components. As an example, we’ll take a look at angular-three-cannon NgtcPhysics component.

NgtcPhysics is a component that provides the Physics World for THREE.js 3D object using Cannon.js.

Here’s a list of most of the inputs that NgtcPhysics component accepts with their default values:

1
const defaultOptions: NgtcPhysicsInputs = {
2
allowSleep: false,
3
axisIndex: 0,
4
broadphase: 'Naive',
5
defaultContactMaterial: { contactEquationStiffness: 1e6 },
6
frictionGravity: null,
7
gravity: [0, -9.81, 0],
8
isPaused: false,
9
iterations: 5,
10
maxSubSteps: 10,
11
quatNormalizeFast: false,
12
quatNormalizeSkip: 0,
13
shouldInvalidate: true,
14
size: 1000,
15
solver: 'GS',
16
stepSize: 1 / 60,
17
tolerance: 0.001,
18
};

Individual Input approach

Normally, we would author NgtcPhysics with each individual Input for each option

1
...
2
import { input } from '@angular/core';
3
4
@Component({})
5
export class NgtcPhysics {
6
allowSleep = input(false);
7
axisIndex = input(0);
8
9
broadphase = input('Naive');
10
defaultContactMaterial = input({ contactEquationStiffness: 1e6 });
11
frictionGravity = input(null);
12
gravity = input([0, -9.81, 0]);
13
isPaused = input(false);
14
iterations = input(5);
15
maxSubSteps = input(10);
16
quatNormalizeFast = input(false);
17
quatNormalizeSkip = input(0);
18
shouldInvalidate = input(true);
19
size = input(1000);
20
solver = input('GS');
21
stepSize = input(1 / 60);
22
tolerance = input(0.001);
23
}

The advantage of this approach is it allows the consumers of NgtcPhysics to only provide the options they want to override. For example, if the consumers only want to override the gravity, they can do so like this:

1
<ngtc-physics [gravity]="[0, -20, 0]">
2
<!-- content -->
3
</ngtc-physics>

However, the above approach has a few drawbacks:

1
@Component({})
2
export class NgtcPhysics {
3
4
defaultContactMaterial = input({ contactEquationStiffness: 1e6 });
5
isPaused = input(false);
6
solver = input('GS');
7
8
9
broadphase = input<NgtcPhysicsInputs['broadphase']>('Naive');
10
}
1
new CannonWorkerAPI(options);

We can fix this second drawback by creating a computed with all the inputs

1
import { input, computed } from '@angular/core';
2
3
@Component({})
4
export class NgtcPhysics {
5
/* truncated */
6
7
options = computed(() => ({
8
allowSleep: this.allowSleep(),
9
axisIndex: this.axisIndex(),
10
broadphase: this.broadphase(),
11
defaultContactMaterial: this.defaultContactMaterial(),
12
frictionGravity: this.frictionGravity(),
13
gravity: this.gravity(),
14
isPaused: this.isPaused(),
15
iterations: this.iterations(),
16
maxSubSteps: this.maxSubSteps(),
17
quatNormalizeFast: this.quatNormalizeFast(),
18
quatNormalizeSkip: this.quatNormalizeSkip(),
19
shouldInvalidate: this.shouldInvalidate(),
20
size: this.size(),
21
solver: this.solver(),
22
stepSize: this.stepSize(),
23
tolerance: this.tolerance(),
24
}));
25
26
constructor() {
27
28
afterNextRender(() => {
29
30
const worker = new CannonWorkerAPI(this.options());
31
})
32
}
33
}

This is verbose and will be tedious when we have more components that require this approach. As great Angular developers as we all are, we will probably go ahead and create a Custom Inject Function (CIF) for this.

Please take a look at ngxtension inputs for the implementation of this CIF

With that in mind, maintaining components with many inputs with default values is somewhat troublesome with all the workarounds. Here comes Object Inputs

Object Input approach

Personally, I’ve voiced my opinion on this before in regards to Props Spreading and many people have told me to use Object Inputs. While I solved the issue with Services but that is also sub-par and we didn’t have Signal Input at the time.

Let’s revisit NgtcPhysics and turn out inputs into a single options input, with the defaultOptions object as well

1
@Component({})
2
export class NgtcPhysics {
3
4
options = input(defaultOptions);
5
}

This immediately solves both drawbacks of the individual input approach. We don’t have to type each individual input and we can use options() signal directly without any tedious workaround. Additionally, we can also mark options as a required input with input.required which is awesome.

However, we have a big issue that is the consumers cannot override what they only need with our input() as-is

1
<!-- overriding gravity only -->
2
<ngtc-physics [options]="{ gravity: [0, -20, 0] }">
3
<!-- content -->
4
</ngtc-physics>

This does not work because the object { gravity: [0, -20, 0] } that the consumers pass in will override the entire defaultOptions; so the other default options are lost.

The fix is simple though: transform is the answer

1
@Component({})
2
export class NgtcPhysics {
3
options = input(defaultOptions, {
4
5
transform: (options: Partial<NgtcPhysicsInputs>) => ({ ...defaultOptions, ...options }),
6
});
7
}

Now when the consumers pass in { gravity: [0, -20, 0] }, the gravity will override the defaultOptions.gravity but the rest of the default options are preserved.

We can now make a reusable transform function to reuse this logic across multiple components/directives

1
export function mergeOptions<TOptions extends object>(defaultOptions: TOptions) {
2
return (value: Partial<TOptions>) => ({ ...defaultOptions, ...value });
3
}

Let’s refactor NgtcPhysics

1
@Component({})
2
export class NgtcPhysics {
3
4
options = input(defaultOptions, { transform: mergeOptions(defaultOptions) });
5
}

Conclusion

Object Inputs are a great way to provide a single input with default values. This approach is especially useful when we have many inputs with default values. It also allows the consumers to override only the options they want to change. We can also reuse the transform function across multiple components/directives to keep our code DRY.

Have fun and good luck!

Bonus: Directive with Object Inputs

Directives can use the same approach as the Components. However, the consumers can use our directive with the following approach:

1
<div someDirective></div>

That’s right! someDirective can match SomeDirective with [someDirective] as its selector. Imagine someDirective is also an option input with default values, Angular Compiler will throw an error saying '' is not assignable to OptionsType

1
@Directive({ selector: '[someDirective]' })
2
export class SomeDirective {
3
someDirective = input(defaultOptions, { transform: mergeOptions(defaultOptions) });
4
}

The root cause is when we use <div someDirective></div>, we provide an empty string '' to someDirective input and that does not pass the type-checking for Partial<OptionsType>. We can fix this by modifying mergeOptions function as following:

1
export function mergeOptions<TOptions extends object>(defaultOptions: TOptions) {
2
3
return (value: Partial<TOptions> | '') => {
4
5
if (value === '') return defaultOptions;
6
return { ...defaultOptions, ...value };
7
};
8
}

Now the consumers can use <div someDirective></div> without the compiler errors.

Published on Thu Jun 20 2024


Angular