Angular Signals ile durum yönetimi: signal, computed ve effect'i doğru yerde kullanmak
Angular uygulamalarında durum yönetimi uzun süre aynı soruyu doğurdu: bu değer bileşenin alanı mı olsun, servisteki bir BehaviorSubject mi, yoksa bir store kütüphanesine mi taşınsın? Signals bu soruya daha küçük ve daha öngörülebilir bir cevap veriyor: okunduğunda bağımlılığını kaydeden, değiştiğinde yalnızca ona bağlı olanları haberdar eden bir değer kabı.
Bu yazıda signal'in üç temel parçasını, yani signal, computed ve effect'i, her birinin hangi iş için olduğunu ve RxJS ile aralarındaki sınırı nasıl çizdiğimi anlatıyorum. Amaç, "her şeyi signal yap" ya da "hiç dokunma" demek değil; hangi durumun nerede yaşaması gerektiğine karar vermek.
Signal: okunabilir ve yazılabilir bir değer
Signal, bir değeri saran ve fonksiyon gibi çağırarak okuduğunuz bir yapıdır. Yazmak için set ya da update kullanılır:
import { signal } from '@angular/core';
const count = signal(0);
count(); // 0
count.set(5);
count.update(n => n + 1);
Şablonda da aynı şekilde {{ count() }} yazılır. Angular şablonun hangi signal'leri okuduğunu bilir ve yalnızca ilgili signal değiştiğinde o görünümü yeniden değerlendirir. Eskiden bunu async pipe ya da ChangeDetectorRef ile elle yönetmek gerekiyordu.
Burada kritik bir ayrıntı var: signal değeri aynı referansla set edilirse değişiklik sayılmaz. Bu yüzden nesne ve dizi içeren durumda yerinde mutasyon yapmayın. items().push(x) hiçbir şeyi tetiklemez; items.update(list => [...list, x]) tetikler. Değişmezlik (immutability) signal'lerle zaten doğal bir alışkanlık haline gelir.
Computed: türetilmiş durumu saklamayın, hesaplayın
En sık gördüğüm hata, türetilebilen bir değeri ayrı bir alanda saklayıp elle güncel tutmaya çalışmak. Sepet toplamı, filtrelenmiş liste, "form gönderilebilir mi" bilgisi gibi değerler başka durumdan çıkarılabiliyorsa computed ile tanımlanmalı:
readonly items = signal<CartItem[]>([]);
readonly taxRate = signal(0.2);
readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.quantity, 0));
readonly total = computed(() =>
this.subtotal() * (1 + this.taxRate()));
computed tembeldir ve önbelleğe alır: değeri okunana kadar hesaplanmaz, bağımlılıkları değişmedikçe yeniden hesaplanmaz. Bağımlılıkları da otomatik izlenir; bir bağımlılık listesi yazmanız gerekmez. Bu, "iki alan birbirinden kopmuş" türünden hataları ortadan kaldırır, çünkü doğruluk kaynağı tektir.
Bir computed içinde yan etki yapmayın. İçeride bir HTTP isteği başlatmak ya da başka bir signal'e yazmak, değerin ne zaman hesaplanacağını bilmediğiniz için sürprizlere yol açar. Computed saf bir fonksiyon gibi davranmalı.
Effect: yan etki için, durum aktarmak için değil
effect, signal'ler değiştiğinde çalışan bir yan etkidir: localStorage'a yazmak, bir analitik olayı göndermek, üçüncü taraf bir grafik kütüphanesini güncellemek gibi Angular dışındaki dünyayla konuşan işler için uygundur.
constructor() {
effect(() => {
localStorage.setItem('cart', JSON.stringify(this.items()));
});
}
Effect'in yanlış kullanımı ise bir signal değiştiğinde başka bir signal'i set etmektir. Bu, zinciri okumayı zorlaştırır ve döngü riskini doğurur. Böyle bir ihtiyaç görürseniz çoğunlukla ikinci değer aslında bir computed olmalıdır. Kendime koyduğum kural şu: effect'in çıktısı Angular'ın dışına çıkıyorsa effect, uygulama durumunun içinde kalıyorsa computed.
Ayrıca effect bir enjeksiyon bağlamında oluşturulmalıdır; bileşen ya da servis yapıcısı bunun için doğal yerdir. Yaşam döngüsünü Angular yönettiği için elle temizlik yapmanız gerekmez.
Servis içinde durum: dışarıya salt okunur açmak
Paylaşılan durumu küçük bir servisle tutmak çoğu uygulama için yeterlidir. Önemli olan yazma yetkisini servisin içinde tutmak:
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly count = computed(() => this._items().length);
add(item: CartItem) {
this._items.update(list => [...list, item]);
}
remove(id: string) {
this._items.update(list => list.filter(i => i.id !== id));
}
}
Bileşenler items ve count'u okur, ama durumu yalnızca add ve remove gibi adlandırılmış işlemlerle değiştirebilir. Böylece durumun nasıl değişebileceği tek bir dosyada okunur. Bir store kütüphanesinin sağladığı disiplinin büyük kısmını, ek bağımlılık olmadan elde edersiniz. Uygulama büyüyüp zaman yolculuğu, ayrıntılı geliştirme araçları ya da katı bir olay modeli gerekirse o zaman bir kütüphaneyi değerlendirmek mantıklı.
RxJS ile sınır: olay akışı ve anlık değer
Signals, RxJS'in yerini almaz; farklı bir işi yapar. Signal "şu an değer ne?" sorusuna cevap verir ve her zaman bir değeri vardır. Observable ise zaman içindeki olaylar içindir: yazma kutusundaki girdiler, WebSocket mesajları, iptal edilebilir HTTP istekleri, debounceTime, switchMap gibi zamanlama gerektiren işler.
Pratik ayrım şöyle:
- Arayüzün göstereceği durum: signal.
- Zamana yayılan olay akışı ve birleştirme mantığı: RxJS.
- İkisi arasında köprü:
toSignalbir observable'ı signal'e,toObservablebir signal'i observable'a çevirir.
Örneğin arama kutusunda kullanıcı girdisini debounceTime ile süzüp sonucu toSignal ile şablona verebilirsiniz. Zamanlama RxJS'te kalır, şablon ise basit bir signal okur. toSignal ilk değer olmadığında undefined döndürebilir; bunu initialValue seçeneğiyle açıkça belirleyin.
Sık yapılan hatalar
Pratikte karşılaştığım tekrarlayan sorunlar şunlar:
- Yerinde mutasyon. Dizi ya da nesneyi değiştirip signal'in güncelleneceğini beklemek. Her zaman yeni bir referans üretin.
- Computed içinde yan etki. Hesaplama saf kalmalı.
- Effect ile durum senkronizasyonu. Türetilebilen değeri
computedyapın. - Her şeyi signal yapmak. Bir kez okunup atılan yerel değişkenler ya da sabitler signal olmak zorunda değil.
- Okuma yerini yanlış seçmek. Signal'i bir effect ya da computed dışında, örneğin bir olay işleyicisinde okumak sorun değildir ama o okuma bir bağımlılık oluşturmaz. Bağımlılığın izlenmesi gereken yerde okuduğunuzdan emin olun.
Geçişi küçük adımlarla yapın
Mevcut bir uygulamayı tek seferde dönüştürmeye çalışmayın. Yeni bileşenlerde yerel durumu signal ile yazın, BehaviorSubject tutan küçük servisleri bir kerede bir tane signal'e taşıyın, karmaşık akışları RxJS'te bırakın ve köprüyü toSignal ile kurun. Her adımda testler çalışmaya devam eder ve ekip yeni modele alışır.
Signals'in asıl kazancı performanstan önce okunabilirliktir: bir değerin nereden geldiği, neye bağlı olduğu ve kimin değiştirebildiği koda bakınca anlaşılır. signal ile durumu, computed ile türetilmiş değerleri, effect ile dış dünyaya yan etkileri ayrı tutarsanız, Angular kodunuz hem izlemesi hem değiştirmesi daha kolay bir hale gelir.