Angular Signal Inputs are here to change the game 🎲

Enea Jahollari
ITNEXT
Published in
9 min readJan 10, 2024

--

Photo by Erik Mclean on Unsplash

Angular has gone through a lot of changes in the past few years. Since the release of 2.0 Angular embraced decorators and used them to annotate parts of code that should be processed by Angular. We have Component, Directive, Pipe, and Injectable decorators for classes, etc. We also have Input and Output decorators that are used to define the inputs and outputs of a component (public API of a component).

In this article, we will see how Angular is going to reduce the decorator usage for Inputs by using signal inputs and how it will make the code more readable, and easier to understand.

Decorator Inputs

Let’s start with a simple example of a component that has an input property.

@Component({
selector: 'user',
template: `
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
`,
})
export class UserComponent {
@Input() user: User;
}

The @Input decorator is used to define an input property. The user property is an input property and it is used to pass a User object to the component. The User object is then used to render the name and email of the user.

The code above looks ok, but it has a few issues 🤔.

Just by looking at the type, we can see that the user will always be set (it’s not optional). But, we’re still allowed to use the component as:

<user />

However, this will result in an error at runtime because the user property is not set.

Error: Cannot read property 'name' of undefined

We can fix this by making the `user` property optional:

@Input() user?: User;

Now, we need to check if the user is set before using it:

@if (user) {
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
}

or by using the ? operator:

<h1>{{ user?.name }}</h1>
<p>{{ user?.email }}</p>

This is a lot of code for something that should be simple. We can fix this by using a default value:

@Input() user: User = { name: '', email: '' };

Now, we can use the `user` property without checking if it is set. But, what if the `user` object has a lot more fields? We would need to set all of them to a default value. This is not a good solution 🙃.

What we can do is to make the user input required if it’s a core part of the component. We can do this by using the `{ required: true }` option:

@Input({ required: true }) user: User;

This will make the `user` input required and we will get an error if we don’t set it.

<user />

This will result in an error:

Error: Required input 'user' from component UserComponent must be specified.

How can we make these errors appear earlier?

What we can do is enable `strictPropertyInitialization` in the `tsconfig.json` file. This will make the compiler check if all properties are initialized. The code below will result in an error:

// error: Property 'user' has no initializer and is not assigned in the constructor.
@Input() user: User;

To fix this TS error, we either need to set a default value or just set it to `undefined` or `null`:

@Input() user: User | undefined;
// or
@Input() user: User | null = null;
// or
@Input() user?: User;

And then, just like before we need to check if the `user` is set before using it.

If we try to make the `user` input required, we will get the same error:

// error: Property 'user' has no initializer and is not definitely assigned in the constructor.
@Input({ required: true }) user: User;

We can fix this by using the ! operator:

@Input({ required: true }) user!: User;

This is fine because we’re telling the compiler that the `user` property will be set before we use it.

Signal Inputs

In Angular v17.1, a new feature will be introduced — Signal Inputs. This is how they will look like:

export class UserComponent {
user = input<User>();
}

The code above is equivalent to the code below:

export class UserComponent {
@Input() user: User | undefined;
}

The difference is that by default if we don’t make the input required, the `user` property will be set to `undefined`. This is the first thing that signal inputs protect us from ✨.

Required Inputs

If we want to make the `user` input required, we can do it by using the `required` option:

export class UserComponent {
user = input.required<User>();
}

This is equivalent to:

export class UserComponent {
@Input({ required: true }) user!: User;
}

What we can also do is to set a default value:

export class UserComponent {
user = input<User>({ name: '', email: '' });
}

Because the user will always be instantiated, we won’t need to use the ! (non null assertion) anymore 🚀

As we can see, we don’t have to fight with Angular typings and Typescript anymore or have to understand all the inner workings of Angular. We can just use the `input` function and we’re good to go 🚀.

Why doesn’t Angular make all decorator inputs required by default?

The reason is that Angular is used in a lot of different projects. Making all inputs required by default will break a lot of projects. That’s why we need to opt-in for this feature.

Deriving state from inputs

Input setter with a method call

With class fields, what we did to derive the state was to use input setters and call a method that will derive the state from the inputs.

// deriving state from inputs with input setter and calling a method
export class UserComponent {
_user: User | undefined;
_images: string[] = [];

favoriteImages: string[] = [];

@Input() set user(user: User) {
this._user = user;
this.updateFavoriteImages();
}

@Input() set images(images: string[]) {
this._images = images;
this.updateFavoriteImages();
}

private updateFavoriteImages() {
if (this._user && this._images) {
this.favoriteImages = this._images.filter(x => this._user.favoriteImages.includes(x.name));
}
}
}

Input setter with BehaviorSubject

The input setter has the issue of not having access to all input changes, that’s why we used it mostly with BehaviorSubjects and handled the racing conditions with rxjs operators.

// deriving state from inputs with input setter and BehaviorSubject
export class UserComponent {
user$ = new BehaviorSubject<User | undefined>(undefined);
images$ = new BehaviorSubject<string[]>([]);

favoriteImages$ = combineLatest([this.user$, this.images$]).pipe(
map(([user, images]) => {
if (user && images) {
return images.filter(x => user.favoriteImages.includes(x.name));
}
return [];
})
);

@Input() set user(user: User) {
this.user$.next(user);
}

@Input() set images(images: string[]) {
this.images$.next(images);
}
}

ngOnChanges

ngOnChanges on the other hand, is called for every input change and it has access to all the inputs.

// deriving state from inputs with ngOnChanges

export class UserComponent implements OnChanges {
@Input() user: User | undefined;
@Input() images: string[] = [];

favoriteImages: string[] = [];

ngOnChanges(changes: SimpleChanges) {
if (changes.user || changes.images) {
this.updateFavoriteImages();
}
}

private updateFavoriteImages() {
if (this.user && this.images) {
this.favoriteImages = this.images.filter(x => this.user.favoriteImages.includes(x.name));
}
}
}

These are some of the patterns that we used to derive state from inputs.

Signals 🚦

Then signals were introduced and we started to use them to derive state from inputs.

// deriving state from inputs with signals

export class UserComponent {
user = signal<User | undefined>(undefined);
images = signal<string[]>([]);

favoriteImages = computed(() => {
const user = this.user();
if (user && this.images().length) {
return this.images().filter(x => user.favoriteImages.includes(x.name));
}
return [];
});

@Input({ alias: 'user' }) set _user(user: User) {
this.user.set(user);
}

@Input({ alias: 'images' }) set _images(images: string[]) {
this.images.set(images);
}
}

We just refactored the code to use signals instead of BehaviorSubjects. And computed instead of combineLatest.

Signal Inputs

Now, with signal inputs, we can just use the `input` function and we’re good to go 🚀.

// deriving state from inputs with signal inputs

export class UserComponent {
user = input.required<User>(); // Let's make the user input required for this example
images = input<string[]>([]);

favoriteImages = computed(() => {
const user = this.user();
if (user && this.images().length) {
return this.images().filter(x => user.favoriteImages.includes(x.name));
}
return [];
});
}

Inputs + API calls

This was easy to do with BehaviorSubjects because we can just pipe the observables and handle the racing conditions with rxjs operators.

// inputs + api calls with BehaviorSubjects
export class UserComponent {
private imagesService = inject(ImagesService);
user$ = new BehaviorSubject<User | undefined>(undefined);

favoriteImages$ = this.user$.pipe(
switchMap(user => {
if (user) {
return this.imagesService.getImages(user.favoriteImages);
}
return of([]);
})
);

@Input() set user(user: User) {
this.user$.next(user);
}
}

With `ngOnChanges` we can do the same thing, but we need to handle the racing conditions ourselves.

// inputs + api calls with ngOnChanges
export class UserComponent implements OnChanges, OnDestroy {
private imagesService = inject(ImagesService);
@Input() user: User | undefined;

favoriteImages: string[] = [];

private currentSub?: Subscription;

ngOnChanges(changes: SimpleChanges) {
if (changes.user) {
this.updateFavoriteImages();
}
}

private updateFavoriteImages() {
if (this.user) {
this.currentSub?.unsubscribe();

this.currentSub = this.imagesService
.getImages(this.user.favoriteImages)
.subscribe(images => (this.favoriteImages = images));
}
}

ngOnDestroy() {
// unsubscribe from the observable when the component is destroyed
this.currentSub?.unsubscribe();
}
}

That’s too much code for something that should be simple. Also, if the API call depends on more than one input, we need to handle the racing conditions ourselves ⚠️.

Signal Inputs + API calls

To listen to input changes, we use the `effect` function.

In v17, `effect` is scheduled to run after all the inputs are initialized. So, it means even if we have an effect on the constructor, we can be sure that all the inputs are initialized (inside of it).

export class UserComponent {
private imagesService = inject(ImagesService);
user = input.required<User>();

constructor() {
console.log('constructor - user', this.user()); // user is undefined here
effect(() => {
console.log('effect - user', this.user());
});
}

ngOnInit() {
console.log('init - user', this.user()); // user is defined here
}
}
<app-user [user]="user" />

This code will result in the following output:

constructor - user undefined
init - user { name: 'John' };
effect - user { name: 'John' };

Because of this, we can use the `effect` function to make API calls.

// inputs + api calls with signal inputs

export class UserComponent {
private imagesService = inject(ImagesService);
user = input.required<User>();

favoriteImages = signal<string[]>([]);

constructor() {
effect((onCleanup) => {
const sub = this.imagesService.getImages(this.user().favoriteImages).subscribe(images => {
this.favoriteImages.set(images);
});
onCleanup(() => sub.unsubscribe())
});
}
}

This is great until we have to manage multiple input changes and derive state from them, while also maintaining the subscriptions .

computedFrom (ngxtension) to the rescue 🛟

computedFrom was born out of the need to derive state from multiple sources (observables, signals, signals from inputs) and also to manage the subscriptions cleanly (without having to subscribe/unsubscribe manually ✅).

Read the whole story here:

This is how we can use it to derive state from inputs and API calls:

// inputs + api calls with signal inputs + computedFrom

export class UserComponent {
private imagesService = inject(ImagesService);
user = input.required<User>();

favoriteImages = computedFrom(
[this.user], // sources
switchMap(([user]) => this.imagesService.getImages(user.favoriteImages)), // side effect + computation
{ initialValue: [] } // options
);
}

If you have more sources, you can just add them to the array:

favoriteImages = computedFrom(
[this.user, this.imageOptions, /* ... more sources here */ ]
switchMap(([user, options]) =>
this.imagesService.getImages(user.favoriteImages, options)
),
{ initialValue: [] }
);
Another use-case with computedFrom

Migrating to Signal Inputs

You can go over all your inputs and start refactoring every input and its references one by one, or you can use some migration schematics that handle most of the basic usage patterns for you.

The ngxtension library publishes some schematics you can use in an Angular or Nx workspace.

To use it you just have to run these two commands:

npm install ngxtension
ng g ngxtension:convert-signal-inputs

Find more about it in the docs: Ngxtension Signal Inputs Migration by Chau Tran 🪄

To sum up

With signal inputs, we can just use the `input` function and we’re good to go 🚀. We don’t have to fight with Typescript anymore or have to understand all the inner workings of Angular. We can just use the `input` function to derive state directly from inputs, to manage API calls more easily, and if we don’t want to maintain any glue code at all, computedFrom (ngxtension) is there to help us.

The article was reviewed by:

  • Matthieu Riegler 🧑🏻‍💻 (Make sure to follow him on Twitter for everything Angular)

Other articles to read regarding signal inputs:

Get better at Modern Angular ⚡️

Master the latest features to build modern applications. Learn how to use standalone components, function guards & interceptors, signals, the new inject method, and much more.

🔗 Modern Angular workshop from Push-Based.io

Modern Angular Workshop Agenda

Thanks for reading!

If this article was interesting and useful to you, and you want to learn more about Angular, support me by buying me a coffee ☕️ or follow me on X (formerly Twitter) @Enea_Jahollari where I tweet and blog a lot about Angular latest news, signals, videos, podcasts, updates, RFCs, pull requests and so much more. 💎

--

--

GDE for Angular | Software Engineer @ push-based.io | Performance & architecture enthusiast