What's New in Angular 17.x: input(), model(), output() and Signal Queries
input(), input.required(), model(), output() and the viewChild()/viewChildren() signal queries from Angular 17.1–17.3, side by side with their Angular 12 @Input, @Output and @ViewChild counterparts.
Version note: This post covers what Angular 17.1, 17.2 and 17.3 introduced; the code targets Angular 17.3.
From Decorators to Functions
Angular 17 refreshed templates, but the doors a component uses to talk to the outside world were still the same: @Input(), @Output(), @ViewChild(). The next three minor releases turned those doors into signals one by one. input() arrived with 17.1 in January 2024, model() and signal queries with 17.2 in February, and output() with 17.3 in March.
This is where the signal and computed we saw earlier really earn their keep. Once every piece of data coming into a component is a signal, everything derived from it can be written with computed. For each API in this post, we will look at the Angular 12 version first and then the new one.
Reacting to Inputs in Angular 12
Picture a price tag component. The price and the discount come from outside, and we compute the discounted price. With the ngOnChanges we covered earlier:
export class PriceTagComponent implements OnChanges {
@Input() price!: number;
@Input() currency = 'TRY';
@Input() discount = 0;
finalPrice = 0;
ngOnChanges(changes: SimpleChanges): void {
if (changes['price'] || changes['discount']) {
this.finalPrice = this.price * (1 - this.discount / 100);
}
}
}
The calculation is one line, but the ceremony around it is long. We keep finalPrice in sync by hand, check which input changed with string keys, and promise TypeScript with ! that price will really be provided. Add a new input and forget to handle it in ngOnChanges, and the value quietly goes stale.
input() and input.required()
The same component with Angular 17.1:
import { Component, computed, input } from '@angular/core';
@Component({
selector: 'app-price-tag',
standalone: true,
template: `<span>{{ finalPrice() }} {{ currency() }}</span>`
})
export class PriceTagComponent {
price = input.required<number>();
currency = input('TRY');
discount = input(0);
finalPrice = computed(() => this.price() * (1 - this.discount() / 100));
}
input('TRY') defines an input with a default value; its type is inferred from that value. input.required<number>() is a mandatory input. If the parent doesn't provide it, you get a compile error, and there's no need to make promises with !. For an optional input without a default, write input<string>(); its type becomes string | undefined.
You read an input by calling it like a function: price(). ngOnChanges is gone entirely. Since finalPrice is a computed, it updates on its own whenever price or discount changes. Nothing changes on the parent side:
<app-price-tag [price]="1450" [discount]="10" />
One important difference: a signal input is read-only. You can't write this.price.set(...) inside the component. Data flows from parent to child, and the child doesn't change it. The input options we saw earlier are here as well: input(false, { transform: booleanAttribute }) for boolean attributes, and input(1, { alias: 'qty' }) to use a different name in templates.
model(): Two-Way Binding as a Signal
To support the banana-in-a-box syntax we saw earlier in our own component, Angular 12 needed an input plus an output whose name ends in Change:
export class QuantityPickerComponent {
@Input() value = 1;
@Output() valueChange = new EventEmitter<number>();
increase(): void {
this.value++;
this.valueChange.emit(this.value);
}
}
If you changed the value and forgot to emit, the parent never heard about it. model(), which arrived in Angular 17.2, folds the pair into a single line:
import { Component, model } from '@angular/core';
@Component({
selector: 'app-quantity-picker',
standalone: true,
template: `
<button (click)="decrease()">-</button>
<span>{{ value() }}</span>
<button (click)="increase()">+</button>
`
})
export class QuantityPickerComponent {
value = model(1);
increase(): void {
this.value.update(v => v + 1);
}
decrease(): void {
this.value.update(v => Math.max(1, v - 1));
}
}
A model is a writable input. The difference from input() is that the component can change it with set or update. When it does, Angular takes care of notifying the parent; there is still an event called valueChange behind the scenes, but you don't manage it. On the parent side:
@Component({
selector: 'app-cart-line',
standalone: true,
imports: [QuantityPickerComponent],
template: `
<app-quantity-picker [(value)]="quantity" />
<p>Total: {{ quantity() * 250 }} USD</p>
`
})
export class CartLineComponent {
quantity = signal(2);
}
quantity in the parent is a signal; when the child changes the value, the signal updates and the total recalculates on its own. You can bind a plain property there too, and both work. Think of walkie-talkies: input() is a receiver that only listens, while model() has a push-to-talk button.
output(): Events Without EventEmitter
In Angular 12 we sent events from child to parent with @Output() and EventEmitter:
@Output() saved = new EventEmitter<Task>();
With Angular 17.3:
import { Component, input, output } from '@angular/core';
interface Task { id: number; title: string; }
@Component({
selector: 'app-task-editor',
standalone: true,
template: `
<input #title [value]="task().title" />
<button (click)="save(title.value)">Save</button>
<button (click)="cancelled.emit()">Cancel</button>
`
})
export class TaskEditorComponent {
task = input.required<Task>();
saved = output<Task>();
cancelled = output<void>();
save(title: string): void {
this.saved.emit({ ...this.task(), title });
}
}
In the parent you still write (saved)="onSaved($event)"; usage is the same. So what's different? EventEmitter is actually a subclass of RxJS's Subject. That's why people sometimes treated it like an observable, chaining pipe onto it or subscribing from inside. output() is deliberately narrower: all it has is emit. By the way, output() doesn't return a signal; it's simply written in the same function style as the other new APIs.
If you need to connect to RxJS, @angular/core/rxjs-interop has two helpers. outputFromObservable turns an observable into an output, and outputToObservable turns an output into an observable:
private query$ = new Subject<string>();
queryChange = outputFromObservable(this.query$.pipe(debounceTime(300)));
Signal Queries: viewChild, viewChildren, contentChild
In Angular 12, to reach an element in the template we used @ViewChild and waited for ngAfterViewInit for it to be ready:
export class SearchBoxComponent implements AfterViewInit {
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
ngAfterViewInit(): void {
this.searchInput.nativeElement.focus();
}
}
With Angular 17.2:
import { Component, ElementRef, afterNextRender, computed, viewChild, viewChildren } from '@angular/core';
import { TaskItemComponent } from './task-item.component'; // a simple component that renders one task
@Component({
selector: 'app-search-box',
standalone: true,
imports: [TaskItemComponent],
template: `
<input #searchInput />
@for (task of tasks; track task.id) {
<app-task-item [task]="task" />
}
<p>Showing {{ itemCount() }} tasks</p>
`
})
export class SearchBoxComponent {
tasks = [{ id: 1, title: 'Write report' }, { id: 2, title: 'Meeting' }];
searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
items = viewChildren(TaskItemComponent);
itemCount = computed(() => this.items().length);
constructor() {
afterNextRender(() => this.searchInput().nativeElement.focus());
}
}
viewChild returns a signal. If the element doesn't exist yet, or an @if removed it from the screen, its value is undefined, and it updates when the element comes back. viewChild.required is for cases where you guarantee the element is always there; its type has no undefined. viewChildren gives you a signal holding a read-only array instead of a QueryList, so you can derive from it directly with computed.
We did the focusing inside afterNextRender; it runs once after the component renders next time and does the job ngAfterViewInit used to do. The { static: true } option we saw earlier is no longer needed either. For content projection, the same idea gives you contentChild() and contentChildren(), the signal counterparts of @ContentChild and @ContentChildren.
All Together: A Star Rating
Let's see all four APIs in one component:
import { Component, booleanAttribute, computed, input, model, output } from '@angular/core';
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
@for (star of stars(); track star) {
<button (click)="rate(star)" [class.active]="star <= value()">★</button>
}
`
})
export class StarRatingComponent {
max = input(5);
disabled = input(false, { transform: booleanAttribute });
value = model(0);
rated = output<number>();
stars = computed(() => Array.from({ length: this.max() }, (_, i) => i + 1));
rate(star: number): void {
if (this.disabled()) {
return;
}
this.value.set(star);
this.rated.emit(star);
}
}
<app-star-rating [(value)]="score" [max]="10" (rated)="saveScore($event)" />
When max changes, the number of stars updates by itself thanks to computed. value is two-way bound, and rated is an event that fires only when the user clicks. In Angular 12 the same component would have needed an ngOnChanges, a pair of EventEmitters and an array maintained by hand.
What We Gain, Plus a Status Note
The first win is readability: every derived value states what it depends on right in its definition. The second is type safety: with required and types that include undefined, there are fewer empty promises made with !. The third looks ahead: when Angular knows exactly which data in a component changed through signals, it needs Zone.js less. We will dig into that in upcoming posts.
Status note: input() arrived in 17.1, model() and signal queries in 17.2, and output() in 17.3, all in developer preview. All of them became stable in Angular 19. The decorators keep working; you can use @Input() and input() side by side in the same component. In tests you can't assign to a signal input directly, so use fixture.componentRef.setInput('price', 100).
Updating to Angular 17.x
These are minor releases, so no major upgrade is involved. If your project is on 17, the same command takes you to the latest 17.x release:
ng update @angular/core@17 @angular/cli@17
The Node.js requirement is the same as 17.0: ^18.13.0 or ^20.9.0. On the TypeScript side, 17.1 and 17.2 support up to 5.3, and 17.3 supports up to 5.4. You don't have to convert all your existing @Input()s at once; a sensible path is to use the new APIs in components you write from now on and convert older ones when you touch them.
A Small Experiment
Write a user profile card in Angular 12 style: @Input() user, @Input() compact, @Output() follow, an ngOnChanges that joins first and last name, and a @ViewChild that grabs the avatar image. Then rewrite the same component with input.required(), input() with booleanAttribute, output(), computed and viewChild(). Confirm everything still works without touching the parent template. Finally, try using the component in the parent without passing user and read the compile error.
Every door of the component now has a signal on it. In the next post we look at experimental zoneless change detection, where Angular uses that knowledge to update the screen without Zone.js, at control flow and @defer becoming stable, and at @let, which arrived in 18.1.