Not so long ago, we got a new feature for Angular. We can now create inputs using a simple function that returns a signal. It looks nice when the code does not contain the @Input()
decorator, but what’s more important, it’s using a new reactivity approach powered by signals.
1
2
3
4
5
6
| text = input('default value');
//...vs
@Input()
text2 = 'default value';
|
Let’s take a closer look how this new feature works and what are the limitations of it.
How it works
The primary usage is quite simple. To create a new input in your component, all you need to do is add a new field and initialize it with the result of the input(devault_value)
function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| import {Component, input} from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: `
<strong>Child</strong>
{{ text() }}
`
})
export class ChildComponent {
text = input('default value');
}
// ---
@Component({
selector: 'app-root',
standalone: true,
imports: [ChildComponent],
template: `
<strong>Parent</strong>
<app-child [text]="text"/>
`
})
export class AppComponent {
text = 'value from parent';
}
|
The first advantage is that by calling input()
function you need to specify the default value.
It’s important because if you design your component well and take care of these default values, you will achieve a better code. I mean, it’s way easier with inputs of type string
than inputs of type string | null
or string | undefined
. In the second case, you’ll need to check for null or undefined values every time when you access the value. It adds a lot of unnecessary if statements in your code.
But how does it work under the hood…?
So, first of all let’s check the code of input()
function in Angular repository.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| // packages/core/src/authoring/input/input.ts
export function inputFunction<ReadT, WriteT>(
initialValue?: ReadT,
opts?: InputOptions<ReadT, WriteT>): InputSignalWithTransform<ReadT|undefined, WriteT> {
return createInputSignal(initialValue, opts);
}
// ...
export function createInputSignal<ReadT, WriteT>(
initialValue: ReadT,
options?: InputOptions<ReadT, WriteT>): InputSignalWithTransform<ReadT, WriteT> {
const node: InputSignalNode<ReadT, WriteT> = Object.create(INPUT_SIGNAL_NODE);
node.value = initialValue;
// Perf note: Always set `transformFn` here to ensure that `node` always
// has the same v8 class shape, allowing monomorphic reads on input signals.
node.transformFn = options?.transform;
function inputValueFn() {
// Record that someone looked at this signal.
producerAccessed(node);
if (node.value === REQUIRED_UNSET_VALUE) {
throw new RuntimeError(
RuntimeErrorCode.REQUIRED_INPUT_NO_VALUE,
ngDevMode && 'Input is required but no value is available yet.');
}
return node.value;
}
(inputValueFn as any)[SIGNAL] = node;
if (ngDevMode) {
inputValueFn.toString = () => `[Input Signal: ${inputValueFn()}]`;
}
return inputValueFn as InputSignalWithTransform<ReadT, WriteT>;
}
|
Before I started to investigate the subject, I was sure that this function was using the inject()
function to do awesome things with it.
I was wrong.
As you can see, inputFunction
is calling createInputSignal
function, and the only thing that is happening inside is a simple code that creates a node
, assign initialValue
and transformFn
and then create a signal.
So, how is Angular able to recognize a field created with this function as component input…?
The answer is simpler than I thought. All is done by ahead-of-time compilation.
To check it let’s execute yarn build
and check what is inside the dist
directory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| > ~/D/experiment-signal-input main yarn build
yarn run v1.22.21
$ ng build
⠙ Building...
Initial chunk files | Names | Raw size | Estimated transfer size
main-PYYQRTEG.js | main | 169.39 kB | 45.93 kB
polyfills-RT5I6R6G.js | polyfills | 33.10 kB | 10.72 kB
styles-5INURTSO.css | styles | 0 bytes | 0 bytes
| Initial total | 202.50 kB | 56.64 kB
Output location: experiment-signal-input/dist/experiment-signal-input
Application bundle generation complete. [2.581 seconds]
✨ Done in 3.42s.
|
In my example, the important code should be in the main-PYYQRTEG.js
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| var Py = (() => {
let t = class t {
constructor() {
this.text = Tu("default value")
}
};
t.\u0275fac = function (o) {
return new (o || t)
}, t.\u0275cmp = ln({
type: t,
selectors: [["app-child"]],
inputs: {text: [we.SignalBased, "text"]},
standalone: !0,
features: [yn],
decls: 3,
vars: 1,
template: function (o, i) {
o & 1 && (gn(0, "strong"), Zr(1, "Child"), mn(), Zr(2)), o & 2 && (Qi(2), is(" ", i.text(), " "))
},
encapsulation: 2
});
let e = t;
return e
})(), Ad = (() => {
let t = class t {
constructor() {
this.text = "value from parent"
}
};
t.\u0275fac = function (o) {
return new (o || t)
}, t.\u0275cmp = ln({
type: t,
selectors: [["app-root"]],
standalone: !0,
features: [yn],
decls: 3,
vars: 1,
consts: [[3, "text"]],
template: function (o, i) {
o & 1 && (gn(0, "strong"), Zr(1, "Parent"), mn(), vn(2, "app-child", 0)), o & 2 && (Qi(2), os("text", i.text))
},
dependencies: [Py],
encapsulation: 2
});
let e = t;
return e
})();
|
I know it’s not easy to read, but that’s what it looks like after optimization.
In this code Py
is our ChildComponent
, Ad
is AppComponent
. As you can see, inputs are added during Angular compilation. It’s important, because it means that inputs are not resolved in runtime, and you cannot easily create a custom “clone” of input()
function.
To be honest, I shouldn’t be surprised. We have ahead-of-time compilation for a reason.
What is not working and why it’s important
Now that we know that the input()
function is used by the compiler to add information about inputs during the compilation process, we should talk about limitations. Compiler is not running your code, so you need to use input()
in a very specific way.
Let’s check what is not working.
The first example of code when input()
will not work is presented below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| import {Component, input} from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: `
<strong>Child</strong>
{{ text() }}
`
})
export class ChildComponent {
text = input('default value');
constructor() {
const fullname = input('');
}
}
// result
var Py = (() => {
let t = class t {
constructor() {
this.text = Si("default value");
let r = Si("")
}
};
t.\u0275fac = function (o) {
return new (o || t)
}, t.\u0275cmp = ln({
type: t,
selectors: [["app-child"]],
inputs: {text: [we.SignalBased, "text"]},
standalone: !0,
features: [yn],
decls: 3,
vars: 1,
template: function (o, i) {
o & 1 && (gn(0, "strong"), Zr(1, "Child"), mn(), Zr(2)), o & 2 && (Ki(2), ss(" ", i.text(), " "))
},
encapsulation: 2
});
let e = t;
return e
})()
|
After adding a code to the constructor, we still have only one input. Let’s try to change it fullname
from local variable to a field and test it again.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| import {Component, input, InputSignal} from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: `
<strong>Child</strong>
{{ text() }}
`
})
export class ChildComponent {
text = input('default value');
fullname!: InputSignal<string>;
constructor() {
this.fullname = input('');
}
}
// result
var Py = (() => {
let t = class t {
constructor() {
this.text = Si("default value"), this.fullname = Si("")
}
};
t.\u0275fac = function (o) {
return new (o || t)
}, t.\u0275cmp = ln({
type: t,
selectors: [["app-child"]],
inputs: {text: [we.SignalBased, "text"]},
standalone: !0,
features: [yn],
decls: 3,
vars: 1,
template: function (o, i) {
o & 1 && (gn(0, "strong"), Zr(1, "Child"), mn(), Zr(2)), o & 2 && (Ki(2), ss(" ", i.text(), " "))
},
encapsulation: 2
});
let e = t;
return e
})()
|
Still no change. Input was not added, even if in the code it looks like something that might work.
The second non-working case is when you try to wrap input in other functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| import {Component, input} from '@angular/core';
function addInputs() {
return input('');
}
@Component({
selector: 'app-child',
standalone: true,
template: `
<strong>Child</strong>
{{ text() }}
`
})
export class ChildComponent {
text = input('default value');
fullname = addInputs();
}
// result
var Fy = (() => {
let t = class t {
constructor() {
this.text = Si("default value"), this.fullname = Py()
}
};
t.\u0275fac = function (o) {
return new (o || t)
}, t.\u0275cmp = ln({
type: t,
selectors: [["app-child"]],
inputs: {text: [we.SignalBased, "text"]},
standalone: !0,
features: [yn],
decls: 3,
vars: 1,
template: function (o, i) {
o & 1 && (gn(0, "strong"), Zr(1, "Child"), mn(), Zr(2)), o & 2 && (Ki(2), ss(" ", i.text(), " "))
},
encapsulation: 2
});
let e = t;
return e
})()
|
Even if you try to inline the function, compiler will not recognize a new input.
1
2
3
4
5
| @Component({/* ... */})
export class ChildComponent {
text = input('default value');
fullname = (() => input(''))();
}
|
This case is quite important for me. When I heard about signal inputs for the first time, I was quite sure that it will allow me to wrap them in a function and create a smart composite signals.
For example:
1
2
3
4
5
6
| export function fullname() {
const firstname = input('');
const lastname = input('');
return computed(() => firstname() + ' ' + lastname());
}
|
Would be nice of this function to add two new inputs and encapsulate some business logic inside, so I can easily reuse it in my components. I can imagine, that with synergy with inject()
it would let you build a very useful code.
Unfortunately, as we proved, it’s not working. At least in Angular 17.2, that I used for my tests.
Summary
I believe that inject()
function is a very nice and clean looking way to add inputs to your components. It has a lot to offer, but you’re obligated to use it in “correct” way.
For now, it won’t let you build more complex functions that you can reuse in your components. It’s worth remembering that it works thanks to the Angular compiler, and it’s not adding inputs in runtime.