Angular 17.x Yenilikleri: input(), model(), output() ve Signal Query'ler
Angular 17.1–17.3 ile gelen input(), input.required(), model(), output() ve viewChild()/viewChildren() signal query'lerini, Angular 12'deki @Input, @Output ve @ViewChild karşılıklarıyla yan yana anlatıyoruz.
Sürüm notu: Bu yazı Angular 17.1, 17.2 ve 17.3 ile gelen yenilikleri anlatır; kodlar Angular 17.3 içindir.
Decorator'dan Fonksiyona
Angular 17 template'leri yeniledi, ama component'in dış dünyayla konuştuğu kapılar hâlâ eskisi gibiydi: @Input(), @Output(), @ViewChild(). Sonraki üç ara sürüm bu kapıları tek tek signal'a çevirdi. Ocak 2024'te 17.1 ile input(), Şubat'ta 17.2 ile model() ve signal query'ler, Mart'ta da 17.3 ile output() geldi.
Daha önce gördüğümüz signal ve computed burada asıl işlevini kazanıyor. Component'e dışarıdan gelen her veri bir signal olunca, ondan türeyen her şeyi computed ile yazabiliyorsun. Bu yazıda her API için önce Angular 12'deki hâlini, sonra yenisini göreceğiz.
Angular 12'de Input'a Tepki Vermek
Bir fiyat etiketi component'i düşün. Fiyat ve indirim dışarıdan geliyor, indirimli fiyatı biz hesaplıyoruz. Daha önce gördüğümüz ngOnChanges ile:
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);
}
}
}
Hesap tek satır ama etrafındaki tören uzun. finalPrice'ı elle senkron tutuyoruz, hangi input'un değiştiğini string anahtarlarla kontrol ediyoruz, price'ın gerçekten verileceğini de ! ile TypeScript'e söz veriyoruz. Yeni bir input eklediğinde ngOnChanges'a eklemeyi unutursan hesap sessizce eskide kalıyor.
input() ve input.required()
Angular 17.1 ile aynı component:
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') varsayılan değeri olan bir input tanımlıyor; tipi değerden çıkarılıyor. input.required<number>() ise zorunlu bir input. Parent onu vermezse derleme hatası alıyorsun ve ! ile söz vermeye gerek kalmıyor. Varsayılanı olmayan, opsiyonel bir input istersen input<string>() yazarsın; tipi string | undefined olur.
Okurken input'u fonksiyon gibi çağırıyorsun: price(). ngOnChanges tamamen ortadan kalktı. finalPrice bir computed olduğu için price ya da discount değiştiğinde kendiliğinden güncelleniyor. Parent tarafında hiçbir şey değişmiyor:
<app-price-tag [price]="1450" [discount]="10" />
Önemli bir fark: signal input salt okunur. Component içinden this.price.set(...) yazamazsın. Veri parent'tan child'a akar, child onu değiştirmez. Daha önce gördüğümüz input ayarları burada da var: input(false, { transform: booleanAttribute }) ile boolean attribute, input(1, { alias: 'qty' }) ile template'te farklı bir isim kullanabiliyorsun.
model(): İki Yönlü Binding'in Signal Hâli
Daha önce gördüğümüz banana-in-a-box sözdizimini kendi component'imizde desteklemek için Angular 12'de bir input ve adı Change ile biten bir output çifti yazıyorduk:
export class QuantityPickerComponent {
@Input() value = 1;
@Output() valueChange = new EventEmitter<number>();
increase(): void {
this.value++;
this.valueChange.emit(this.value);
}
}
Değeri değiştirip emit etmeyi unutursan parent haberdar olmuyordu. Angular 17.2 ile gelen model() bu ikiliyi tek satıra indiriyor:
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));
}
}
model, yazılabilir bir input. input()'tan farkı, component'in onu set ya da update ile değiştirebilmesi. Değiştirdiğinde parent'a haber verme işini Angular üstleniyor; arkada yine valueChange adında bir event var, ama onu sen yönetmiyorsun. Parent tarafında:
@Component({
selector: 'app-cart-line',
standalone: true,
imports: [QuantityPickerComponent],
template: `
<app-quantity-picker [(value)]="quantity" />
<p>Toplam: {{ quantity() * 250 }} TL</p>
`
})
export class CartLineComponent {
quantity = signal(2);
}
Parent'taki quantity bir signal; çocuk değeri değiştirdiğinde signal da güncelleniyor ve toplam kendiliğinden yeniden hesaplanıyor. İstersen orada düz bir property de bağlayabilirsin, ikisi de çalışıyor. Bir telsiz gibi düşün: input() sadece dinleyen bir alıcı, model() ise bas-konuş tuşu olan bir telsiz.
output(): EventEmitter'sız Event
Angular 12'de child'dan parent'a event göndermek için @Output() ve EventEmitter kullanıyorduk:
@Output() saved = new EventEmitter<Task>();
Angular 17.3 ile:
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)">Kaydet</button>
<button (click)="cancelled.emit()">Vazgeç</button>
`
})
export class TaskEditorComponent {
task = input.required<Task>();
saved = output<Task>();
cancelled = output<void>();
save(title: string): void {
this.saved.emit({ ...this.task(), title });
}
}
Parent'ta yine (saved)="onSaved($event)" yazıyorsun, kullanım aynı. Peki fark ne? EventEmitter aslında RxJS'teki Subject'in bir alt sınıfı. Bu yüzden insanlar onu bazen bir observable gibi kullanır, üzerine pipe takar, içeriden subscribe olurdu. output() bilerek daha dar: sadece emit var. Bu arada output() bir signal döndürmüyor; sadece diğer yeni API'lerle aynı fonksiyon tarzında yazılıyor.
RxJS ile bağ kurman gerekiyorsa @angular/core/rxjs-interop içinde iki yardımcı var. outputFromObservable bir observable'ı output'a, outputToObservable da bir output'u observable'a çeviriyor:
private query$ = new Subject<string>();
queryChange = outputFromObservable(this.query$.pipe(debounceTime(300)));
Signal Query'ler: viewChild, viewChildren, contentChild
Angular 12'de template'teki bir elemana erişmek için @ViewChild kullanır ve onun hazır olması için ngAfterViewInit'i beklerdik:
export class SearchBoxComponent implements AfterViewInit {
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
ngAfterViewInit(): void {
this.searchInput.nativeElement.focus();
}
}
Angular 17.2 ile:
import { Component, ElementRef, afterNextRender, computed, viewChild, viewChildren } from '@angular/core';
import { TaskItemComponent } from './task-item.component'; // tek bir görevi çizen basit bir component
@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>{{ itemCount() }} görev listeleniyor</p>
`
})
export class SearchBoxComponent {
tasks = [{ id: 1, title: 'Rapor yaz' }, { id: 2, title: 'Toplantı' }];
searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
items = viewChildren(TaskItemComponent);
itemCount = computed(() => this.items().length);
constructor() {
afterNextRender(() => this.searchInput().nativeElement.focus());
}
}
viewChild bir signal döndürüyor. Eleman henüz yoksa ya da bir @if yüzünden ekrandan kalktıysa değeri undefined oluyor, geri geldiğinde güncelleniyor. viewChild.required ise elemanın her zaman orada olduğunu garanti ettiğin durumlar için; tipte undefined yok. viewChildren da QueryList yerine salt okunur bir dizi taşıyan signal veriyor, bu yüzden computed ile doğrudan türetebiliyorsun.
Odaklanmayı afterNextRender içinde yaptık; bu fonksiyon component bir sonraki kez çizildikten sonra bir kez çalışıyor ve ngAfterViewInit'in işini görüyor. Daha önce gördüğümüz { static: true } ayarına da gerek kalmadı. Content projection tarafında aynı mantıkla contentChild() ve contentChildren() var; @ContentChild ve @ContentChildren'ın signal karşılıkları.
Hepsi Bir Arada: Yıldızlı Puanlama
Dört API'yi tek bir component'te görelim:
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)" />
max değiştiğinde yıldız sayısı computed sayesinde kendiliğinden güncelleniyor. value iki yönlü bağlı, rated ise sadece kullanıcı tıkladığında haber veren bir event. Angular 12'de aynı component bir ngOnChanges, bir EventEmitter çifti ve elle tutulan bir dizi gerektirirdi.
Ne Kazandık? Bir de Durum Notu
İlk kazanç okunabilirlik: türetilmiş her değer, neye bağlı olduğunu kendi tanımında söylüyor. İkincisi tip güvenliği: required ve undefined içeren tipler sayesinde ! ile verilen boş sözler azalıyor. Üçüncüsü ileriye dönük: Angular, bir component'in hangi verisinin değiştiğini signal'lar üzerinden tam olarak bildiğinde Zone.js'e daha az ihtiyaç duyuyor. Bu yönü ilerleyen yazılarda detaylandıracağız.
Durum notu: input() 17.1'de, model() ve signal query'ler 17.2'de, output() 17.3'te developer preview olarak geldi. Hepsi Angular 19'da stable oldu. Decorator'lar ise çalışmaya devam ediyor; aynı component'te @Input() ile input()'u yan yana kullanabilirsin. Test yazarken signal input'a doğrudan atama yapamadığın için fixture.componentRef.setInput('price', 100) kullanman gerekiyor.
Angular 17.x'e Güncelleme
Bunlar ara sürümler olduğu için major geçiş gerekmiyor. Projen 17'deyse aynı komut seni en son 17.x sürümüne taşıyor:
ng update @angular/core@17 @angular/cli@17
Node.js gereksinimi 17.0 ile aynı: ^18.13.0 ya da ^20.9.0. TypeScript tarafında 17.1 ve 17.2 5.3'e kadar, 17.3 ise 5.4'e kadar destekliyor. Mevcut @Input()'larını toplu hâlde dönüştürmek zorunda değilsin; yeni yazdığın component'lerde yeni API'leri kullanıp eskilerine dokunduğunda çevirmek makul bir yol.
Küçük Bir Deneme
Angular 12 tarzında bir kullanıcı profili kartı yaz: @Input() user, @Input() compact, @Output() follow, ad soyadı birleştiren bir ngOnChanges ve avatar resmine erişen bir @ViewChild. Sonra aynı component'i input.required(), input() ile booleanAttribute, output(), computed ve viewChild() kullanarak yeniden yaz. Parent template'ine dokunmadan her şeyin çalıştığını gör. Son olarak component'i parent'ta user vermeden kullanmayı dene ve derleme hatasını oku.
Artık component'in her kapısında signal var. Angular'ın bu bilgiyi kullanıp Zone.js olmadan ekranı güncellediği deneysel zoneless change detection'ı, stable hâle gelen control flow ve @defer'i, 18.1 ile gelen @let'i bir sonraki yazıda göreceğiz.