Signal Tabanlı Service State: Okunur Signal, computed ve Angular 22'nin @Service() Decorator'ı ile Store Yazmak
Library'siz bir Angular store'u private bir writable signal, asReadonly() ile açılan okunur görünüm, computed ile türetilmiş değerler ve state'i immutable güncelleyen method'lardan oluşur; ömrünü onu sağlayan injector belirler. Bookstore'un sepetini Angular 22'nin @Service() decorator'ıyla yazıyor, aynı array'i döndüren update'in, withAutoCleanupInjectors() olmadan hiç ölmeyen route-level store'un ve kendi signal'ına yazan effect'in sonuçlarını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, TypeScript 6.0 ve Node.js 24 ile denenmiştir (Angular 22.2 projeleri TypeScript 6.0 kullanıyor); tarayıcı davranışı Playwright 1.65 ile headless Chromium'da, snapshot'taki sahte Bookstore API'sine karşı gözlenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'de sepet bir service'te, private bir signal'da duruyor; header sepet sayısını count adlı bir computed'dan okuyor. Bir ekip arkadaşı add method'unu "daha kısa" olsun diye şöyle yazmış:
add(book: Book): void {
// yanlış: array'i değiştirip aynı referansı döndürüyor
this.#items.update((items) => {
items.push({ isbn: book.isbn, title: book.title, price: book.price, quantity: 1 });
return items;
});
console.log(`[cart] array length ${this.#items().length}, count() ${this.count()}`);
}
Kullanıcı katalogda önce Dune'u, sonra Emma'yı sepete ekliyor. Konsolda iki satır çıkacak ve header bir şey gösterecek. Array'in uzunluğu kaç olur, count() ne döner, header'da ne yazar? Bu satırları yazmadan önce tahminini netleştir; cevap "Immutable Güncelleme" bölümünde.
Önceki yazıda state'in türlerini ve nerede yaşaması gerektiğini ayırdık. Bu yazıda paylaşılan client state'i, yani sepeti, NgRx gibi bir library kullanmadan, yalnız Angular'ın kendi araçlarıyla gerçek bir store'a çeviriyoruz: private writable signal ve okunur görünüm, computed ile türetilmiş değerler, immutable güncelleme, Angular 22'nin @Service() decorator'ı, provider scope ile state ömrü, linkedSignal ve effect'in doğru yeri. Signal API'lerinin kendisi Angular serisinde ("Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed", "Angular 19: Varsayılan Standalone, linkedSignal ve resource() ile Veri Yükleme"); unidirectional data flow fikri UI Mimari Pattern'leri serisinin "Angular'da Unidirectional Data Flow: Signal, input/output ve Service'te State" yazısında.
Elle Yazılmış Bir Store: Private Signal, Okunur Görünüm
Sepetin bu yazının sonundaki hâli:
import { computed, effect, Service, signal } from '@angular/core';
import type { Book, CartItem } from '../models';
const storageKey = 'bookstore.cart';
// Global client state: bütün uygulama için tek instance (root injector)
@Service()
export class CartStore {
readonly #items = signal<CartItem[]>(readSavedCart());
// State'in salt okunur görünümü; her değişiklik aşağıdaki method'lardan geçer
readonly items = this.#items.asReadonly();
readonly count = computed(() => this.#items().reduce((sum, item) => sum + item.quantity, 0));
readonly totalPrice = computed(() =>
this.#items().reduce((sum, item) => sum + item.price * item.quantity, 0),
);
constructor() {
// Yalnız dışarıya senkronizasyon: sepet her değiştiğinde localStorage'a yaz
effect(() => localStorage.setItem(storageKey, JSON.stringify(this.#items())));
}
add(book: Book): void {
this.#items.update((items) =>
items.some((item) => item.isbn === book.isbn)
? items.map((item) =>
item.isbn === book.isbn ? { ...item, quantity: item.quantity + 1 } : item,
)
: [...items, { isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }],
);
}
setQuantity(isbn: string, quantity: number): void {
if (quantity < 1) {
this.remove(isbn);
return;
}
this.#items.update((items) =>
items.map((item) => (item.isbn === isbn ? { ...item, quantity } : item)),
);
}
remove(isbn: string): void {
this.#items.update((items) => items.filter((item) => item.isbn !== isbn));
}
clear(): void {
this.#items.set([]);
}
}
function readSavedCart(): CartItem[] {
try {
const saved = localStorage.getItem(storageKey);
return saved ? (JSON.parse(saved) as CartItem[]) : [];
} catch {
return []; // storage engellenmiş ya da JSON bozuk: boş sepetle başla
}
}
Yapı dört kuraldan oluşuyor. Birincisi, state'in tek bir sahibi var: #items. JavaScript'in # ile yazılan private field'ı, TypeScript'in private'ından farklı olarak runtime'da da erişilemez; ne bir component ne de bir test bu signal'a doğrudan ulaşabilir. İkincisi, dışarıya açılan items, asReadonly() ile alınmış bir Signal<CartItem[]>; okunabiliyor ama set ya da update method'u yok. Üçüncüsü, türetilmiş her değer (count, totalPrice) bir computed; ayrı bir field'da saklanmıyor. Dördüncüsü, her değişiklik isimli bir method'dan geçiyor: add, setQuantity, remove, clear.
Son kural küçük görünüyor ama store'u store yapan şey bu. "Sepetteki bir satırın adedi sıfıra inerse satır silinir" kuralı setQuantity'nin içinde; adet değiştiren her yer bu kuralı ücretsiz alıyor. Signal'ı dışarı açsaydın aynı kuralı her component kendi yazacaktı ve biri unutacaktı. Okunur görünümün derleme zamanında ne kazandırdığını "Sık Düşülen Tuzaklar"da gerçek bir hata mesajıyla göreceğiz.
Header, sepet sayfası ve checkout bu instance'ı inject(CartStore) ile alıp signal'ları template'te okuyor. Component'lerin kodunda hiçbir subscription yok: Angular 22'de component'ler varsayılan olarak OnPush ve uygulama zoneless; template'te okunan bir signal değişince Angular yalnız o view'ı yeniden kontrol ediyor. Bu varsayılanların ayrıntısı Angular serisinin "Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı" yazısında.
@Service() ve inject()
Angular 22, bir service'i root'ta sağlamak için kısa bir yol getirdi: @Service(). Angular dokümantasyonu onu @Injectable({ providedIn: 'root' }) yazımının kısaltması olarak tanımlıyor ve ng generate service artık doğrudan bunu üretiyor. Sınırları da belli: bağımlılıklar yalnız inject() fonksiyonuyla alınabiliyor, constructor parametresiyle injection desteklenmiyor; useClass, useValue gibi gelişmiş provider seçenekleri yerine tek bir factory seçeneği var; 'platform' gibi root dışı scope'lar yok. Bunlardan birine ihtiyacın varsa @Injectable geçerliliğini koruyor.
Store yazarken önemli olan seçenek autoProvided: false. Varsayılan hâliyle @Service() class'ı root injector'a kendisi kaydediyor; autoProvided: false ile bu kayıt kapanıyor ve service ancak bir providers listesine eklendiği yerde var oluyor. Bir sonraki bölümdeki checkout store'u tam olarak böyle.
inject() bir de tasarım kararını kolaylaştırıyor: store'un bağımlılıkları field olarak, kullanıldıkları yerin hemen yanında duruyor (readonly #cart = inject(CartStore)). Constructor'ın tek işi, gerekiyorsa effect kaydetmek ya da log yazmak gibi kurulum adımları.
Immutable Güncelleme: Aynı Array Neden Ekranı Güncellemiyor?
"Önce Tahmin Et"teki add, gerçek bir tarayıcıda:
0.9 s console: [cart] array length 1, count() 0
1.2 s console: [cart] array length 2, count() 0
header : Cart (0)
localStorage: []
Array iki elemana çıktı ama count() hâlâ 0, header "Cart (0)" diyor ve localStorage'a hiçbir şey yazılmadı. Sebep, signal'ın değişikliği nasıl anladığı. update, callback'in döndürdüğü değeri set ile yazıyor ve set yeni değeri eskisiyle Object.is ile karşılaştırıyor. Callback aynı array referansını döndürdüğü için signal açısından hiçbir şey değişmedi: version artmadı, ona bağlı computed'lar cache'teki sonucu döndürdü, effect çalışmadı ve OnPush component'lerin hiçbiri yeniden kontrol edilmedi. İşin kötüsü veri gerçekten değişti; bir sonraki gerçek güncellemede hepsi birden görünecek ve hata rastgele gibi duracak.
Doğru yazımda add her zaman yeni bir array döndürüyor: ya map ile adedi artırılmış yeni bir satır, ya da spread ile sona eklenmiş yeni bir satır. Değişmeyen satırlar aynı referansla kalıyor; bu, @for'un track item.isbn ile değişmeyen DOM (sayfanın element ağacı) element'lerini olduğu gibi bırakmasına da yarıyor. Kural kısaca şu: signal'ın içindeki object ve array'lere dokunma, yenisini üret.
effect Yalnız Dışarıya Senkronizasyon İçin
Önceki yazıdaki koşuda sepet sayfa yenilemesinde kayboluyordu. Store'un constructor'ındaki tek effect bunu çözüyor: sepet her değiştiğinde localStorage'a yazıyor, uygulama açılırken de readSavedCart() başlangıç değerini oradan okuyor. Dune'u iki kez, Emma'yı bir kez ekleyip sayfayı yeniledik:
after adding : Cart (3)
after reload : Cart (3)
cart page : Total: 1220 TRY
localStorage : [{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}]
Bu, effect'in Angular dokümantasyonunun da önerdiği kullanımı: signal state'ini signal olmayan bir API ile (localStorage, bir chart library'si, analytics) senkron tutmak. effect state üretmiyor, dışarıya yazıyor. Root'ta oluşturulan bir effect, uygulamanın change detection'ından önce ve asenkron çalışıyor; yani ardı ardına üç add çağrısı localStorage'a üç ayrı yazım değil, son hâlin tek yazımını üretiyor. Aynı asenkronluk, test yazarken effect'i tetiklemek için ayrıca bir adım atman gerektiği anlamına geliyor; o konu serinin test yazısında.
readSavedCart'taki try/catch süs değil. Tarayıcı storage'ı engellenmiş olabilir ya da kayıtlı JSON eski bir sürümün şemasında olabilir; ikisinde de sepet boş başlamalı, uygulama açılmamazlık etmemeli. Kalıcı state'in sürümlenmesi ve sekmeler arası tutarlılık bu yazının kapsamı dışında; şimdilik hata durumunda boş sepete düşüyoruz.
Provider Scope = State Ömrü
Sepet root'ta; uygulama yaşadıkça yaşaması doğru. Checkout ise iki adımlı bir akış (kargo seçimi, sipariş özeti) ve iki adımın paylaştığı state, kullanıcı checkout'tan çıkınca unutulmalı. Bu state'i root'a koymak önceki yazıdaki "gereğinden yukarıdaki state" hatası olur; component'e koymak ise adımlar arasında kaybolmasına yol açar. Doğru yer, checkout route'unun kendi injector'ı:
import { computed, DestroyRef, inject, Service, signal } from '@angular/core';
import { CartStore } from '../cart/cart-store';
export type ShippingMethod = 'standard' | 'express';
let instances = 0;
// Feature state: root'ta sağlanmaz; 'checkout' route'u kendi providers listesinde verir
@Service({ autoProvided: false })
export class CheckoutStore {
readonly #cart = inject(CartStore);
readonly #instance = ++instances;
readonly #shipping = signal<ShippingMethod>('standard');
readonly shipping = this.#shipping.asReadonly();
readonly shippingPrice = computed(() =>
this.#shipping() === 'express' ? 90 : this.#cart.totalPrice() >= 500 ? 0 : 40,
);
readonly payable = computed(() => this.#cart.totalPrice() + this.shippingPrice());
constructor() {
console.log(`[checkout] store #${this.#instance} created`);
inject(DestroyRef).onDestroy(() => console.log(`[checkout] store #${this.#instance} destroyed`));
}
chooseShipping(method: ShippingMethod): void {
this.#shipping.set(method);
}
}
import { Routes } from '@angular/router';
import { CartPage } from './cart/cart-page';
import { CatalogPage } from './catalog/catalog-page';
import { CheckoutReview } from './checkout/checkout-review';
import { CheckoutShipping } from './checkout/checkout-shipping';
import { CheckoutStore } from './checkout/checkout-store';
export const routes: Routes = [
{ path: '', component: CatalogPage },
{ path: 'cart', component: CartPage },
{
path: 'checkout',
// bütün checkout adımları için tek CheckoutStore; kullanıcı checkout'tan çıkınca biter
providers: [CheckoutStore],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'shipping' },
{ path: 'shipping', component: CheckoutShipping },
{ path: 'review', component: CheckoutReview },
],
},
];
providers listesi olan bir route için router ayrı bir EnvironmentInjector oluşturuyor; shipping ve review child route'ları aynı CheckoutStore instance'ını görüyor. Peki kullanıcı checkout'tan çıkınca bu injector'a ne oluyor? Senaryo: Dune sepette, kullanıcı checkout'a giriyor, express kargoyu seçiyor, özet sayfasına geçiyor, sonra kataloğa dönüp yeniden checkout'a giriyor. Önce router'ın varsayılan ayarıyla:
1.5 s console: [checkout] store #1 created
step 1: shipping selected = standard
step 2: Books 450 TRY + express shipping 90 TRY = 540 TRY
left checkout for the catalog
back in checkout: shipping selected = express
Store bir kez oluştu ve hiç destroy edilmedi; ikinci ziyarette kullanıcı önceki ziyaretten kalan express seçimini buluyor. Router, varsayılan olarak route injector'larını uygulama boyunca saklıyor. Angular 22.2'de stable olan withAutoCleanupInjectors() feature'ı bunu değiştiriyor: router, artık aktif olmayan route'ların injector'larını (varsayılan RouteReuseStrategy buna izin veriyor) navigation sonrasında destroy ediyor. Uygulamanın router ayarı:
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding(), withAutoCleanupInjectors()),
provideHttpClient(),
],
};
Aynı senaryo:
1.5 s console: [checkout] store #1 created
step 1: shipping selected = standard
step 2: Books 450 TRY + express shipping 90 TRY = 540 TRY
2.3 s console: [checkout] store #1 destroyed
left checkout for the catalog
2.9 s console: [checkout] store #2 created
back in checkout: shipping selected = standard
Kullanıcı kataloğa geçince #1 destroy edildi (DestroyRef.onDestroy log'u), geri dönüşte #2 temiz bir state'le başladı. Provider scope'un state ömrünü belirlemesi tam olarak bu: state'i sıfırlamak için kod yazmadın, injector'ın ömrü state'in ömrü oldu. Bu yüzden route-level store kullanıyorsan bu ayarı açık bir karar olarak ver; açık değilse "feature state" aslında root state gibi davranıyor.
Üçüncü seviye component'in kendi providers listesi: @Component({ providers: [X] }) yazınca o component'in her instance'ı kendi X'ini alıyor ve component destroy olunca X de gidiyor. Aynı sayfada iki bağımsız kopyası olması gereken state (iki ayrı kitap karşılaştırma paneli gibi) için uygun. Bu seviyeyi NgRx Signal Store yazısında, component seviyesinde sağlanan bir store ile tekrar göreceğiz.
linkedSignal: Kaynağına Bağlı Ama Değiştirilebilir State
Katalog artık sayfalı. Sayfa numarası local state: kullanıcı "Next" ile değiştiriyor. Ama kategori değişince 1'e dönmesi gerekiyor; "classics"in 2. sayfasındayken "Fantasy"ye geçen kullanıcı, tek sayfalık bir kategorinin 2. sayfasında kalmamalı. Aynı şey kapak dialog'u için de geçerli: kategori değişince kapanmalı. İki state de "kaynağından türetilen ama elle de değiştirilebilen" türden; computed yazılamaz, düz signal ise kaynağını bilmiyor. linkedSignal bu ikisinin arası:
export class CatalogPage {
// URL state: ?category=classics, withComponentInputBinding() input'a bağlar
readonly category = input<string>();
// Kategori her değiştiğinde 1'den yeniden başlayan local state
protected readonly page = linkedSignal({ source: this.category, computation: () => 1 });
// Server state: API'deki verinin kopyası, kategori ya da sayfa değişince yeniden çekilir
protected readonly books = httpResource<BookPage>(() => {
const params: Record<string, string | number> = { page: this.page() };
const category = this.category();
if (category) params['category'] = category;
return { url: `${apiUrl}/books`, params };
});
// Global client state: header ve sepet sayfasıyla paylaşılır
protected readonly cart = inject(CartStore);
// Local UI state: kategori değişince kapak dialog'u kapanır
protected readonly coverOf = linkedSignal<string | undefined, Book | null>({
source: this.category,
computation: () => null,
});
source değiştiğinde computation çalışıyor ve sonucu signal'ın yeni değeri oluyor; arada set ile istediğin değeri yazabiliyorsun. Tarayıcıda:
0.1 s request: GET /books?page=1&category=classics
open classics 4 books, page 1 of 2 | dialog: closed
0.8 s request: GET /books?page=2&category=classics
click Next 4 books, page 2 of 2 | dialog: closed
open a cover 4 books, page 2 of 2 | dialog: open
2.3 s request: GET /books?page=1&category=fantasy
switch to Fantasy 1 books, page 1 of 1 | dialog: closed
"Next" sayfa 2'yi getirdi, kategori değişince sayfa 1'e döndü ve dialog kapandı; httpResource da yeni parametrelerle tek bir istek attı. Bu sıfırlamayı bir effect ile de yazabilirdin; Angular dokümantasyonu bir signal'dan diğerine değer kopyalayan effect'leri, state'in yanlış yerde durduğunun işareti sayıyor ve bu durumlar için computed ya da linkedSignal öneriyor. linkedSignal'da sıfırlama kuralı, sayfa numarasının tanımlandığı satırda duruyor; ayrıca tetiklenmesi gereken bir adım yok. Bir de not: sayfa numarası hâlâ bellekte, yani yenilemede kayboluyor ve link'le paylaşılamıyor. Dördüncü yazı onu URL'e taşıyacak.
Üretimde Dikkat
Root'taki bir store, kullanıcı oturumu kapattığında kendiliğinden sıfırlanmaz. Sepet için clear() var; çıkış akışı onu çağırmalı ve localStorage'daki kopyayı da silmeli, yoksa aynı tarayıcıdaki bir sonraki kullanıcı önceki kullanıcının sepetini görür. Kullanıcıya özel her root store'a bir sıfırlama method'u yazıp çıkış akışından çağırmak, bunu unutmamanın en basit yolu.
localStorage'a yazılan her şey o tarayıcıdaki her script tarafından okunabilir. Sepetin ISBN (kitabın uluslararası standart numarası), başlık ve adetten oluşması sorun değil; token, adres ya da ödeme bilgisi oraya yazılmamalı. Storage'ın senkron bir API olduğunu da unutma: büyük bir state'i her değişiklikte JSON.stringify ile yazmak, sık değişen bir state'te ana thread'i meşgul eder. Sepet için önemsiz, binlerce satırlık bir listede ölçülmesi gereken bir maliyet.
Route-level store'larda withAutoCleanupInjectors() kararını erken ver ve ekibe duyur. Bu ayar mevcut bir uygulamada açılırsa, route injector'larının yaşamaya devam etmesine (bilerek ya da bilmeden) güvenen kod davranış değiştirir: önceki ziyaretten kalan bir seçim artık gelmez. Bu genelde istenen davranış, ama release notunda yazması gereken bir değişiklik.
Sık Düşülen Tuzaklar
WritableSignal'ı dışarı açmak
readonly items = signal<CartItem[]>([]) yazmak daha kısa, ama o zaman readonly yalnız field'ın yeniden atanmasını engelliyor; signal'ın kendisi herkese yazılabilir. Okunur görünüm açıldığında, sepet sayfasına "Empty cart" butonu eklemek isteyen biri doğrudan signal'a yazmayı denerse derleyici durduruyor:
✘ [ERROR] TS2339: Property 'set' does not exist on type 'Signal<CartItem[]>'. [plugin angular-compiler]
src/app/cart/cart-page.ts:32:20:
32 │ this.cart.items.set([]);
╵ ~~~
Hata tam istediğimiz yerde: doğru çözüm store'a bir clear() method'u eklemek (bizimkinde zaten var), component'te state'i elle değiştirmek değil.
Okuduğu signal'a yazan effect
"Adedi 0'a düşen satırları temizleyelim" fikriyle store'a şu effect eklenirse:
// yanlış: okuduğu signal'a yazan bir effect
effect(() => {
console.log('[cart] cleanup effect runs');
this.#items.set(this.#items().filter((item) => item.quantity > 0));
});
filter her seferinde yeni bir array döndürüyor; effect okuduğu signal'ı yeni bir referansla değiştiriyor, değişiklik effect'i yeniden çalıştırıyor. Uygulamayı açtıktan sonraki üç saniye:
after 1 s: effect ran 12513 times
after 2 s: effect ran 31295 times
after 3 s: effect ran 50641 times
header: TimeoutError
Angular bir hata fırlatmadı; effect saniyede 17 ila 19 bin kez çalıştı, sayfa hiç render edilmedi ve header iki saniyelik beklemede bile bulunamadı. Bu, Angular dokümantasyonunun effect'leri state yaymak için kullanmamayı önermesinin somut sebebi. Temizlik kuralının yeri, değişikliği yapan method: setQuantity adedi 1'in altına düşen satırı zaten siliyor.
Derived değeri effect ile kopyalamak
Döngü üretmese bile effect(() => this.#total.set(...)) tarzı bir kopya, bir signal'ı başka bir signal'a taşımak için effect kullanmak demek. Kopya bir adım geride kalıyor: effect asenkron çalıştığı için, değişikliği yapan method'un hemen ardından okunan kopya henüz eski toplamı taşıyor. Hesaplanabilen değer computed, kaynağa bağlı ama elle değişebilen değer linkedSignal.
Route-level store'un ömrünü varsaymak
Yukarıdaki iki çıktı aynı kodun iki farklı router ayarıyla ürettiği sonuçlar. providers'a koymak tek başına "checkout'tan çıkınca sıfırlanır" anlamına gelmiyor; withAutoCleanupInjectors() olmadan store uygulama yaşadıkça yaşıyor.
Küçük Bir Deneme
★☆☆ Sepet sayfasına bir "Empty cart" butonu ekle ve store'daki clear() ile bağla. Butona bastıktan sonra sayfayı yenile: localStorage'daki kopya da boşaldı mı? Neden?
★★☆ CheckoutStore'a linkedSignal ile bir shipping yaz: sepet tutarı 500 TRY'yi (Türk lirası) geçince seçim 'standard''a dönsün (kargo bedava olduğu için), ama kullanıcı yine elle 'express' seçebilsin. computation'ın previous parametresini kullanarak, tutar zaten 500'ün üstündeyken kullanıcının seçimini koru.
★★★ Sepete "son eklenen kitap" bilgisini ekle ve header'da 3 saniye boyunca "Emma added" göster. Bunu store'da bir signal ve component'te bir effect + setTimeout ile yaz; sonra aynı bilgiyi effect kullanmadan, store'un method'unda tutulan bir zaman damgası ve computed ile yazmayı dene. Hangisi test etmesi daha kolay, hangisi gerçekten bir event?
Bir sonraki yazıda katalogdaki kitap listesine, yani server state'e dönüyoruz: httpResource'un loading, error ve reloading durumları, yorum eklerken optimistic update ve hata olursa geri alma, başarıdan sonra reload(), chain() ile birbirine bağlı resource'lar, aynı verinin iki component tarafından iki kez çekilmesi ve NgRx 22'nin resource extension'ları.