Mert Özen Her Satırda Daha İleri
Frontend Angular — #24

Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed

Mert Özen 31 Eki 2026 8 dk 0 görüntülenme
Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed

Angular 16 ile gelen signal, computed ve effect'i, toSignal/toObservable köprüsünü, required input ve transform'u, takeUntilDestroyed ile abonelik temizliğini Angular 12 karşılaştırmasıyla anlatıyoruz.

Sürüm notu: Bu yazı Angular 16 ile gelen yenilikleri anlatır; kodlar Angular 16 içindir.

Angular 16 Neden Ayrı Bir Eşik

Şimdiye kadar baktığımız sürümler aynı evin odalarını yeniden düzenledi. Standalone ile NgModule'ü opsiyonel hâle getirdik, guard ve interceptor'ları sade fonksiyonlara çevirdik. 3 Mayıs 2023'te çıkan Angular 16 ise evin tesisatına dokunuyor. Angular'ın ekranı ne zaman ve nasıl güncellediği sorusuna yeni bir cevap getiriyor. O cevabın adı signal.

Bu yazıda signal'ın üç temel parçasını, RxJS ile nasıl konuştuğunu, input'lara gelen iki küçük ama çok işe yarayan ayarı, abonelik temizliğinin yeni yolunu ve router'ın parametreleri doğrudan input'a bağlamasını göreceğiz. En sonda da 15'ten 16'ya geçişin kısa notu var.

Angular 12'de State Böyle Tutuluyordu

Daha önce gördüğümüz change detection mantığını hatırla. Component'te düz bir property tutarsın, Zone.js bir click'i, timer'ı ya da HTTP cevabını yakalar, Angular da component ağacını yukarıdan aşağı dolaşıp neyin değiştiğine bakar:

@Component({
  selector: 'app-cart-badge',
  template: `
    <span>{{ count }} ürün · {{ count * 120 }} TL</span>
    <button (click)="add()">Ekle</button>
  `
})
export class CartBadgeComponent {
  count = 0;

  add(): void {
    this.count++;
  }
}

Çalışıyor, ama Angular'ın elinde "neyin değiştiği" bilgisi yok. count sıradan bir sayı; onu kimin okuduğunu, değişince kimin etkileneceğini bilmiyor. Bu yüzden şüphelendiği her yere bakmak zorunda. Bir apartman görevlisinin, binada bir yerde su kaçağı olduğunu duyunca bütün daireleri tek tek gezmesi gibi.

signal, computed ve effect

Aynı component Angular 16'da şöyle yazılabiliyor:

import { Component, computed, effect, signal } from '@angular/core';

@Component({
  selector: 'app-cart-badge',
  standalone: true,
  template: `
    <span>{{ count() }} ürün · {{ total() }} TL</span>
    <button (click)="add()">Ekle</button>
  `
})
export class CartBadgeComponent {
  count = signal(0);
  total = computed(() => this.count() * 120);

  constructor() {
    effect(() => console.log('Sepetteki ürün sayısı:', this.count()));
  }

  add(): void {
    this.count.update(c => c + 1);
  }
}

signal(0) başlangıç değeri 0 olan bir kutu oluşturur. Kutunun içine bakmak için onu fonksiyon gibi çağırırsın: count(). Değeri değiştirmenin iki yolu var. set(5) yeni değeri doğrudan yazar, update(c => c + 1) ise eski değerden yenisini hesaplar.

computed, başka signal'lardan türeyen salt okunur bir signal. İçinde hangi signal'ları okuduğunu kendisi takip eder. count değişmedikçe hesabı tekrarlamaz, değiştiğinde de bir sonraki okumada yeniden hesaplar. Excel'deki formül hücresi gibi düşün: A1 değişince B1 kendiliğinden güncellenir, formülü yeniden yazmazsın.

effect ise okuduğu signal'lardan biri değiştiğinde yeniden çalışan bir yan etki. Loglama, localStorage'a yazma gibi işler için uygun. Bir kuralı var: injection context içinde oluşturulmalı. Bu yüzden onu constructor'da tanımladık. Daha önce gördüğümüz inject() fonksiyonunun kuralıyla aynı.

Asıl fark template'te. Template count()'u okuduğunda Angular bu bağlantıyı kaydediyor. Görevli artık kaçağın hangi dairede olduğunu biliyor. Angular 16'da change detection hâlâ Zone.js ile tetikleniyor, yani signal'lar mevcut mekanizmayı kaldırmıyor. Ama Zone.js'siz çalışabilen bir Angular'ın temeli tam olarak bu bilgi; o kısmı ilerleyen yazılarda detaylandıracağız.

Durum notu: Angular 16'da signal, computed, effect ve birazdan göreceğimiz RxJS köprüsü developer preview. Yani kullanabilirsin, ama API sonraki sürümlerde değişebilir. Production'da kullanacaksan bunu bilerek karar ver.

RxJS ile Köprü: toSignal ve toObservable

Signal geldi diye RxJS kenara atılmıyor. HTTP istekleri, event stream'leri, debounce isteyen arama kutuları hâlâ observable'ın alanı. Angular 16 ikisinin arasına köprü kuran iki fonksiyonu @angular/core/rxjs-interop paketiyle getiriyor:

import { Component, inject, signal } from '@angular/core';
import { NgFor } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs';

interface Product { id: number; name: string; }

@Component({
  selector: 'app-product-search',
  standalone: true,
  imports: [NgFor],
  template: `
    <input (input)="query.set($any($event.target).value)" />
    <p *ngFor="let p of results()">{{ p.name }}</p>
  `
})
export class ProductSearchComponent {
  private http = inject(HttpClient);

  query = signal('');

  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      switchMap(q => this.http.get<Product[]>(`/api/products?q=${q}`))
    ),
    { initialValue: [] }
  );
}

toObservable(this.query), signal her değiştiğinde değer yayan bir observable üretir. Ona alıştığımız operatörleri takıyoruz: 300 milisaniye bekle, sonra isteği at, yeni harf gelirse eski isteği iptal et. toSignal da sonucu tekrar signal'a çeviriyor, template'te results() diye okuyoruz.

initialValue vermemizin sebebi şu: observable ilk değeri hemen yaymayabilir. Vermezsen signal'ın tipi Product[] | undefined olur ve template'te her seferinde undefined kontrolü yapman gerekir. Güzel tarafı, toSignal aboneliği kendisi açıp component yok olurken kendisi kapatıyor. Daha önce gördüğümüz async pipe'ın yaptığı işi class tarafına taşımış gibi. İki fonksiyon da injection context içinde çağrılmalı; burada field initializer olduğu için sorun yok.

Input'lara İki Yeni Ayar: required ve transform

Angular 12'de zorunlu bir input'u ve boolean bir bayrağı şöyle yazıyorduk:

export class UserCardComponent implements OnInit {
  @Input() userId!: number;
  @Input() compact = false;

  ngOnInit(): void {
    if (this.userId == null) {
      throw new Error('userId zorunlu');
    }
  }
}

İki dertli nokta var. userId'yi vermeyi unutursan bunu ancak çalışma anında, konsolda öğrenirsin. <app-user-card compact> yazdığında ise compact'e true değil, boş string gelir; strict template kontrolü açıksa bu zaten tip hatası verir. Angular 16 ile:

import { Component, Input, booleanAttribute, numberAttribute } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `<p>Kullanıcı #{{ userId }}</p>`
})
export class UserCardComponent {
  @Input({ required: true }) userId!: number;
  @Input({ transform: booleanAttribute }) compact = false;
  @Input({ transform: numberAttribute }) maxItems = 5;
}

required: true 16.0 ile geldi. Parent userId'yi bağlamazsa artık derleme hatası alıyorsun; hata, kullanıcıya değil sana çıkıyor. transform ise 16.1 ile geldi ve input'a gelen değeri property'ye yazmadan önce bir fonksiyondan geçiriyor. Angular'ın hazır verdiği booleanAttribute ve numberAttribute en sık iki ihtiyacı karşılıyor:

<app-user-card [userId]="42" compact maxItems="10"></app-user-card>

Burada compact gerçekten true, maxItems da string değil, 10 sayısı oluyor. Kendi transform fonksiyonunu da yazabilirsin; tek şart girdiyi alıp dönüştürülmüş değeri döndürmesi.

Abonelik Temizliği: DestroyRef ve takeUntilDestroyed

Daha önce gördüğümüz memory leak konusunu hatırla. Angular 12'de bir aboneliği güvenle kapatmak için bu töreni yapıyorduk:

export class ClockComponent implements OnInit, OnDestroy {
  now = new Date();
  private destroy$ = new Subject<void>();

  ngOnInit(): void {
    interval(1000)
      .pipe(takeUntil(this.destroy$))
      .subscribe(() => this.now = new Date());
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Bir Subject, iki interface, iki lifecycle hook. Her component'te aynı kalıp. Angular 16'da:

import { Component } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({
  selector: 'app-clock',
  standalone: true,
  template: `{{ now.toLocaleTimeString() }}`
})
export class ClockComponent {
  now = new Date();

  constructor() {
    interval(1000)
      .pipe(takeUntilDestroyed())
      .subscribe(() => this.now = new Date());
  }
}

takeUntilDestroyed() çağrıldığı yerin yaşam süresini kendisi bulur ve component yok olduğunda stream'i tamamlar. Parametresiz hâli injection context ister, yani constructor ya da field initializer. Aboneliği ngOnInit gibi başka bir yerde açıyorsan arkasındaki parçayı açıkça vermen gerekir:

export class ClockComponent implements OnInit {
  private destroyRef = inject(DestroyRef);

  ngOnInit(): void {
    interval(1000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => console.log('tik'));

    const id = setInterval(() => console.log('tak'), 5000);
    this.destroyRef.onDestroy(() => clearInterval(id));
  }
}

DestroyRef, "ben yok olurken şunu da yap" demenin genel yolu. onDestroy ile RxJS dışındaki temizlikleri de kaydedebiliyorsun. Bir yardımcı fonksiyonun, kendisini çağıran component'in ömrüne bağlanabilmesi de bu sayede mümkün. takeUntilDestroyed rxjs-interop paketinde olduğu için o da bu sürümde developer preview.

Route Parametresi Doğrudan Input'a

Daha önce gördüğümüz routing konusunda bir ürün detay sayfası parametreyi şöyle okuyordu:

export class ProductDetailComponent implements OnInit {
  productId: string | null = null;

  constructor(private route: ActivatedRoute) { }

  ngOnInit(): void {
    this.route.paramMap.subscribe(params => {
      this.productId = params.get('id');
    });
  }
}

Angular 16'da daha önce gördüğümüz provideRouter'a tek bir özellik ekleyerek bu işi router'a bırakabiliyorsun:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      [{ path: 'products/:id', component: ProductDetailComponent }],
      withComponentInputBinding()
    )
  ]
});

// product-detail.component.ts
export class ProductDetailComponent {
  @Input() id!: string;
}

withComponentInputBinding() açıkken router; route parametrelerini, query parametrelerini ve route data'sını, adı eşleşen input'lara yazıyor. Component'in ActivatedRoute'u bilmesine gerek kalmıyor, test ederken de sahte bir route kurmak yerine input'u doğrudan veriyorsun. Kullanıcı aynı component içinde başka bir ürüne geçerse input güncelleniyor; buna tepki vermek istersen ngOnChanges ya da bir setter kullanabilirsin.

Build Tarafında ve Arka Planda Neler Var

CLI 16 ile esbuild tabanlı builder developer preview oldu. Denemek için angular.json'da build hedefinin builder satırını değiştirmen yeterli:

"builder": "@angular-devkit/build-angular:browser-esbuild"

Webpack'e göre belirgin şekilde hızlı build alıyorsun, ama bazı seçenekler henüz desteklenmiyor olabilir. Bunu bir sonraki adımda varsayılan olacak yeni build sisteminin habercisi gibi gör. CLI tarafında başka notlar da var: ng new --standalone ile NgModule'süz proje oluşturmak artık mümkün ama hâlâ isteğe bağlı, ng generate guard ve resolver da varsayılan olarak fonksiyonel versiyonlarını üretiyor.

Kırıcı değişikliklerin en önemlisi ngcc'nin kaldırılması. Yani hâlâ eski View Engine formatında yayımlanmış bir kütüphane kullanıyorsan Angular 16'da çalışmaz. SSR tarafında da provideClientHydration() ile tahribatsız hydration developer preview olarak geldi; server-side rendering konusuna ilerleyen yazılarda kısaca değineceğiz.

Angular 16'ya Geçiş: ng update

Daha önce gördüğümüz gibi ng update sürümleri tek tek atlatıyor. 15'teki bir projeyi şöyle yükseltiyorsun:

ng update @angular/core@16 @angular/cli@16

Başlamadan önce ortamı kontrol et. Angular 16, Node.js tarafında ^16.14.0 ya da ^18.10.0 istiyor; Node 14 desteği bitti. TypeScript için 4.9.3 ile 5.1 arası bir sürüm gerekiyor, zone.js de ~0.13.0'a çıkıyor. Bir de bağımlılıklarına göz at: View Engine formatında kalmış bir paket varsa güncellemeden önce onun yeni sürümünü bul ya da alternatifini düşün. Signal'lara geçmek ise zorunlu değil; mevcut kodun olduğu gibi çalışmaya devam ediyor.

Küçük Bir Deneme

Küçük bir sıcaklık çevirici yaz. celsius = signal(20) tanımla, fahrenheit'ı computed ile hesapla, iki butonla dereceyi bir artırıp bir azalt. Constructor'a her değişikliği loglayan bir effect ekle. Sonra interval(5000) ile her beş saniyede dereceyi bir artıran bir abonelik aç ve onu takeUntilDestroyed() ile bağla. Component'i bir *ngIf ile ekrandan kaldırıp konsoldaki logların durduğunu gör; ardından takeUntilDestroyed()'ı silip aynı denemeyi tekrarla.

Bu denemede template'i hâlâ *ngIf ve *ngFor ile yazdık. Bir sonraki yazıda Angular 17 ile gelen yeni control flow sözdizimine, @defer ile parça parça yüklenen görünümlere ve varsayılan hâle gelen yeni application builder'a bakacağız.