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.
NgtcPhysicsis 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:
1const 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...2import { input } from '@angular/core';3
4@Component({})5export 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:
- Some inputs are missing type information. This is easy to fix though by providing the type argument to
input()
1@Component({})2export 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}- Second drawback is that it is hard to observe whenever any input changes, kind of like
ngOnChanges. Moreover, it is also tricky to use all the inputs as an object as an option object to something. For this particular example, we’re using the inputs to create aCannonWorkerAPI
1new CannonWorkerAPI(options);We can fix this second drawback by creating a computed with all the inputs
1import { input, computed } from '@angular/core';2
3@Component({})4export 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({})2export 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({})2export 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
1export function mergeOptions<TOptions extends object>(defaultOptions: TOptions) {2 return (value: Partial<TOptions>) => ({ ...defaultOptions, ...value });3}Let’s refactor NgtcPhysics
1@Component({})2export 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]' })2export 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:
1export 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.