Mert Özen Her Satırda Daha İleri
Frontend Angular'da State Yönetimi — #10

Doğru State Aracını Seçmek ve Angular State'in Evrimi: BehaviorSubject'ten Signal Store'a

Mert Özen 14 Kas 2026 13 dk 15 görüntülenme
Doğru State Aracını Seçmek ve Angular State'in Evrimi: BehaviorSubject'ten Signal Store'a

Doğru state aracı state'in türünden çıkar: türetilen değer computed, server state resource, paylaşılabilir state URL, client state ise kapsamına göre signal, signal service, Signal Store, Events plugin ya da klasik Store. Bookstore'un dokuz feature'ı için yaptığımız seçimleri, snapshot'lardan ölçülen bundle maliyetini, store'lar arası bağımlılık kurallarını, NgRx Toolkit withDevtools'u ve BehaviorSubject'ten Signal Store'a uzanan, sürümleri doğrulanmış evrimi topluyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2, NgRx 22.0.1, @angular-architects/ngrx-toolkit 22.0.0, TypeScript 6.0 ve Node.js 24 ile denenmiştir (Angular 22.2 projeleri TypeScript 6.0 kullanıyor); bundle boyutları ng build (production) çıktısından, tarayıcı davranışı Playwright 1.65 ile headless Chromium'da gözlenmiştir. Sürüm tarihleri NgRx CHANGELOG'u, npm ve NgRx sürüm duyurularından doğrulanmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web, bu serinin her yazısında bir snapshot olarak derlendi. Sekizinci yazıda klasik NgRx Store (@ngrx/store, @ngrx/effects, @ngrx/store-devtools) ve onu kullanan back office sayfası eklendi. Bu ekleme, production build'in initial bundle'ını sıkıştırılmış hâliyle yaklaşık ne kadar büyüttü: 1 kB mı, 10 kB mı, 50 kB mı? Tahminini yaz; ölçüm "Maliyet: Bundle Boyutu" bölümünde.

Bu, serinin son yazısı. Dokuz yazı boyunca Bookstore'da yedi farklı state aracı kullandık: component signal'ı ve signalState, URL, signal tabanlı service, resource, NgRx Signal Store, Events plugin ve klasik NgRx Store. Burada hepsini yan yana koyup hangisinin ne zaman seçileceğini, Bookstore'da neyi neden seçtiğimizi, store'ları büyürken düzenli tutmanın kurallarını ve bu araçların nereden geldiğini bir arada ele alıyoruz.

Karar Matrisi

10-tool-matrix-tr.png

Matrisi yukarıdan aşağı oku ve ihtiyacını karşılayan ilk satırda dur. İlk soru ilk yazıdakiyle aynı: bu state'in türü ne? Türetilebiliyorsa saklanmıyor (computed). API'nin verisiyse server state ve yeri bir resource; bir store'a kopyalamak, bayatlamayı ve senkronizasyonu senin işin hâline getiriyor. Paylaşılabilir bir link, yenileme ve geri tuşunda korunması gerekiyorsa URL'de. Geriye client state kalıyor ve burada araç, state'in kaç yerden okunduğu ve değişikliklerinin ne kadar karmaşık olduğuyla büyüyor.

Tek component'in state'i için signal ya da birlikte değişen birkaç alan için signalState yeterli. Birden fazla component aynı state'i paylaşıyorsa bir @Service() ve içinde private signal'lar; Bookstore'un kategori listesi gibi paylaşılan bir resource da böyle bir service'te. State'i değiştiren method'lar iş kuralı taşımaya, async işler, entity koleksiyonları ve tekrar eden parçalar (istek durumu, kalıcılık) eklenmeye başladığında NgRx Signal Store: withEntities, rxMethod ve custom feature'lar elle yazılmış service'in tekrar tekrar çözdüğü işleri hazır veriyor. Birden fazla store aynı olaya tepki veriyorsa Events plugin. Her değişikliğin bir action olarak log'lanması, resmî Redux DevTools desteği ve time travel gerçekten gerekiyorsa ya da codebase zaten öyle yazılmışsa klasik Store.

Matristeki "tören" ve "öğrenme" eksenleri, bir aracın sağladığı her şeyin bir bedeli olduğunu hatırlatıyor: Events plugin ve klasik Store'da bir state değişikliği için event ya da action, reducer ve çoğu zaman bir effect yazıyorsun; ekibe yeni katılan biri akışı tek bir dosyada göremiyor. Bu bedel, birden fazla store'un koordinasyonunda ya da büyük bir ekipte kendini geri ödüyor; tek bir sayfanın state'inde ödemiyor.

Server state için bir not daha: resource ailesi isteklerin paylaşılması (dedupe), arka planda tazeleme ve sayfalar arası cache gibi işleri kendisi yapmıyor; üçüncü yazıda paylaşılan resource'u bir service'e koyarak bunun bir kısmını çözdük. Bu ihtiyaçlar büyükse TanStack Query'nin Angular adaptörü bir alternatif; ancak paket npm'de hâlâ @tanstack/angular-query-experimental adıyla duruyor (yazım günü 5.107.1) ve bu seride çalıştırılmadı.

Bookstore'un Feature'ları İçin Seçimler

FeatureState türüAraçGerekçe
Katalog filtreleri, sayfaURLquery param + input()Paylaşılabilir link, geri tuşu (04)
Kitap listesi, kitap detayıServerhttpResourceAPI'nin kopyası; loading ve error durumu hazır (03)
KategorilerServer, paylaşılan@Service() + httpResourceTek istek, iki component (03)
Kapak dialog'u, yorum formuLocal UIlinkedSignal, signalStateTek component (02, 05)
SepetGlobal clientSignal Store + entitiesHer yerde okunuyor, kalıcı, iş kuralı var (05, 06)
CheckoutFeatureRoute seviyesinde Signal StoreÖmrü checkout ile sınırlı (05)
Hızlı aramaLocal UI + server çağrısıComponent seviyesinde Signal Store + rxMethodDebounce ve iptal (06)
Sipariş akışıBirden çok storeSignal Store + EventsSepet, siparişler ve bildirim aynı olaya tepki veriyor (07)
Back office siparişleriFeature (ayrı ekip)Klasik NgRx StoreAction log'u, DevTools, katı disiplin (08)

Tablodaki son satır bilinçli bir istisna: back office siparişleri bir API listesinin reducer'daki kopyası, yani matrisin alt notunun tersini yapıyor. Klasik Store'da bu yaygın bir kalıp ve sayfa her açıldığında listeyi yeniden çekerek bayatlamayı sınırladık; yeni yazılacak olsaydı bir resource ile başlardık.

Maliyet: Bundle Boyutu

Her snapshot'ı aynı node_modules ile ng build edip initial bundle'ı karşılaştırdık:

SnapshotEklenenInitial (raw)Sıkıştırılmış tahmini
02Yalnız Angular: signal service store'ları252,99 kB68,87 kB
05@ngrx/signals: Signal Store, resource extension'ları263,00 kB71,65 kB
07Entities, rxMethod, @ngrx/operators, Events plugin + yeni sayfalar285,66 kB77,99 kB
08Klasik Store, effects, Store DevTools + back office sayfası326,21 kB89,58 kB
10NgRx Toolkit withDevtools329,49 kB90,54 kB

"Önce Tahmin Et"in cevabı: 07'den 08'e initial bundle sıkıştırılmış tahminiyle 11,59 kB (raw 40,55 kB) büyüdü. Bu sayıyı dikkatli oku: her adımda uygulama kodu da eklendi (07'de arama, siparişler sayfası; 08'de back office sayfası) ve back office route'u lazy load edilmediği için hepsi initial bundle'da. Yine de yön net: 02'den 05'e, Signal Store'un temel hâli ve o arada eklenen kodla birlikte, fark yaklaşık 2,8 kB; klasik Store ve araçlarının getirdiği bunun birkaç katı. Gerçek bir uygulamada back office'i loadChildren ile ayrı bir chunk'a almak, bu farkı müşterinin indirdiği bundle'dan çıkarır.

Mimari: Feature Başına Store ve Bağımlılığın Yönü

Bookstore'da her store bir feature'a ait: CartStore, CheckoutStore, SearchStore, RecentlyViewedStore, OrdersStore, NotificationsStore. Bunun tersi, uygulamanın bütün state'ini tutan tek bir "god store": her feature ona alan ekliyor, her component onu inject ediyor ve sonunda bir alanı değiştirmenin neyi etkilediğini kimse bilmiyor. Klasik Store'da da aynı ilke geçerli: tek global store, ama her feature kendi createFeature'ıyla ayrı bir dilim.

Store'lar arasındaki bağımlılık tek yönlü olmalı. CheckoutStore, CartStore'u inject ediyor (ödenecek tutarı okuyor); sepet ise checkout'u bilmiyor. Ters yönde bir ihtiyaç doğduğunda (sipariş verilince sepetin boşalması) bunu sepete bir inject(CheckoutStore) ekleyerek değil, yedinci yazıdaki gibi bir event ile çözdük. İki store birbirini inject ederse Angular çalışma zamanında bir circular dependency hatası veriyor; ama daha önemlisi, ikisini artık ayrı ayrı anlamak ve test etmek mümkün olmuyor. Kural: feature store'u global store'u okuyabilir, global store feature'ı bilmez; aynı seviyedeki store'lar event ile konuşur.

Store'un ömrü de mimarinin parçası: root store uygulama boyunca yaşıyor ve kullanıcı çıkış yaptığında kendiliğinden sıfırlanmıyor (beşinci yazıdaki clear()); route seviyesindeki store withAutoCleanupInjectors() ile route'tan çıkınca destroy ediliyor (ikinci yazı); component seviyesindeki store component'le birlikte gidiyor (altıncı yazıdaki arama kutusu).

Kalıcılık ve SSR

Sepeti localStorage'a yazmayı ikinci yazıdan beri yapıyoruz ve altıncı yazıda iki kuralı öğrendik: state'in kendisini değil sürümlü bir kopyasını kaydet ve eski biçimleri okuyan bir migration'ı bir süre tut. localStorage senkron ve küçük (tarayıcıya göre birkaç MB); büyük ya da yapılandırılmış veri için IndexedDB daha uygun. NgRx Toolkit'te bunlar için hazır feature'lar var (withStorageSync, withIndexedDB); bu seride kullanmadık, kendi effect'imiz bu ölçek için yeterliydi. Storage API'lerinin kendisi ve sekmeler arası senkronizasyon Browser API'leri ve PWA (kurulabilir web uygulaması) serisinin konusu.

SSR'da (sayfanın sunucuda render edilmesi) state'in bir kısmı sunucuda üretilip HTML'e gömülerek tarayıcıya taşınıyor (TransferState; resource'larda id seçeneği, HttpClient'ta transfer cache). Tehlike, kullanıcıya özel bir yanıtın bu yolla HTML'e girmesi ve o HTML'in bir CDN (içerik dağıtım ağı) ya da paylaşılan bir cache'te başka bir kullanıcıya sunulması. Angular 22'nin HTTP transfer cache'i varsayılan olarak Authorization header'ı taşıyan, credentials ile gönderilen, Cache-Control: private/no-store içeren ya da Set-Cookie döndüren istekleri cache'lemiyor (bunu @angular/common'un tip tanımlarından doğruladık); bu varsayılanları gevşetmeden önce iki kez düşün. Sepet ve siparişler gibi kullanıcıya özel state'i SSR'da hiç üretmemek en güvenli yol. Bu bölüm SSR kurulmadan, dokümantasyon ve tip tanımlarından yazıldı; bu seride çalıştırılmadı.

DevTools: NgRx Toolkit withDevtools

Resmî NgRx paketlerinde Signal Store için Redux DevTools desteği yok; beşinci yazıdan beri söz verdiğimiz topluluk paketi @angular-architects/ngrx-toolkit'in withDevtools'u bu boşluğu dolduruyor. Kurulum npm install @angular-architects/ngrx-toolkit ve store'a tek satır:

  // Topluluk paketi: bu store'un state'ini Redux DevTools'ta 'cart' adıyla gösterir
  withDevtools('cart'),
);

OrdersStore'a da withDevtools('orders') ekledik. Sekizinci yazıdaki gibi, eklentiyi taklit eden bir stub ile iki Dune ekleyip sipariş verme:

 0.1 s console: [devtools] connect name=NgRx Store DevTools
 0.1 s console: [devtools] connect name=NgRx SignalStore
 0.1 s console: [devtools] Store Update | cart: [] idle | no orders store yet
 1.2 s console: [devtools] Store Update | cart: [Dune x 1] idle | no orders store yet
 1.5 s console: [devtools] Store Update | cart: [Dune x 2] idle | no orders store yet
 1.9 s console: [devtools] Store Update | cart: [Dune x 2] idle | orders.ids=[]
 2.2 s console: [event] [Checkout Page] orderSubmitted
 2.2 s console: [devtools] Store Update | cart: [Dune x 2] idle | orders.ids=[]
 2.5 s console: [event] [Orders API] orderPlaced
 2.5 s console: [devtools] Store Update | cart: [] idle | orders.ids=[1043]

Sayfada iki ayrı bağlantı var: klasik Store'un NgRx Store DevTools'u ve toolkit'in NgRx SignalStore'u. Toolkit, withDevtools eklenmiş bütün store'ları tek bir state ağacında, verilen isimlerle (cart, orders) gönderiyor; orders store'u ilk kez inject edildiğinde (checkout sayfası) ağaca katıldı. Her değişiklik varsayılan olarak Store Update adıyla geliyor; yedinci yazıdaki event'ler bile kendi adlarıyla görünmüyor. Paket, değişikliğe isim vermek için patchState yerine kullanılan bir updateState(store, 'action name', ...) ve üretim build'i için withDevToolsStub sunuyor; ikisini de kullanmadık. Bu bir topluluk paketi: sürümleri NgRx'i takip ediyor (22.0.0, peer olarak @ngrx/signals ^22.0.0 istiyor), ama bakımı NgRx ekibinde değil.

Evrim: BehaviorSubject'ten Signal Store'a

Angular 12 döneminde sepet büyük ihtimalle şöyle yazılırdı:

import { Injectable } from '@angular/core';
import { BehaviorSubject, map, type Observable } from 'rxjs';
import type { Book, CartItem } from '../models';

// Sepet, Angular 12 dönemindeki bir uygulamanın yazacağı gibi; yalnız karşılaştırma için (10. yazı), uygulama kullanmıyor
@Injectable({ providedIn: 'root' })
export class LegacyCartService {
  private readonly itemsSubject = new BehaviorSubject<CartItem[]>([]);
  readonly items$: Observable<CartItem[]> = this.itemsSubject.asObservable();
  readonly count$ = this.items$.pipe(map((items) => items.reduce((sum, item) => sum + item.quantity, 0)));

  add(book: Book): void {
    const items = this.itemsSubject.value;
    const line = items.find((item) => item.isbn === book.isbn);
    this.itemsSubject.next(
      line
        ? items.map((item) => (item === line ? { ...item, quantity: item.quantity + 1 } : item))
        : [...items, { isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }],
    );
  }
}

Bu kod bugün de derleniyor ve test ediliyor (snapshot'ta bir testi var, uygulama kullanmıyor):

 ✓ |bookstore-web| src/app/legacy/legacy-cart.service.spec.ts > LegacyCartService > counts copies through an Observable 14ms
 Test Files  10 passed (10)
      Tests  20 passed (20)

Fikir ikinci yazıdaki @Service() store'uyla aynı: private, yazılabilir bir kaynak, dışarıya salt okunur bir görünüm, türetilmiş değerler ve state'i değiştiren method'lar. Fark araçta: her okuma bir subscription (template'te async pipe, kodda firstValueFrom), türetilmiş değer bir pipe(map(...)) ve change detection'ın Observable'dan haberi yok. Signal'lar bu üç noktayı değiştirdi: okuma senkron bir fonksiyon çağrısı, türetme computed, ve zoneless change detection hangi signal'ın değiştiğini biliyor. BehaviorSubject'in hâlâ doğru araç olduğu yer zaman ve event akışları; RxJS serisinin "RxJS Subject Türleri: Subject, BehaviorSubject, ReplaySubject, AsyncSubject ve Ne Zaman Hiçbiri" yazısı bunu anlatıyor.

NgRx tarafında bu serinin kullandığı API'lerin geliş sırası (NgRx CHANGELOG'u, npm yayın tarihleri ve NgRx'in sürüm duyurularından):

  • NgRx 4 (2017): bugünkü @ngrx/platform paket yapısı, createSelector ve createFeatureSelector.
  • NgRx 8 (2019): class'sız creator fonksiyonları createReducer ve createEffect.
  • NgRx 12.1 (Haziran 2021): createFeature. NgRx 14 (2022): createActionGroup.
  • NgRx 15.2 (Ocak 2023): functional effect'ler ve createFeature'da extraSelectors. NgRx 16 (Mayıs 2023): store.selectSignal.
  • NgRx 17 (Kasım 2023): @ngrx/signals (signalStore, signalState) developer preview olarak. @ngrx/signals 18 (Temmuz 2024): stable, korumalı state ve private üyeler.
  • NgRx 19 (Aralık 2024): signalMethod ve withProps. NgRx 19.2 (Mayıs 2025): Events plugin deneysel. NgRx 20 (Temmuz 2025): withLinkedState.
  • NgRx 21 (Aralık 2025): Events plugin stable, withEffects → withEventHandlers, scoped event'ler. NgRx 22 (Ağustos 2026): resource extension'ları, dynamic deep signal'lar, SignalStoreFeatureType, DevTools actionCreators.

Angular tarafında signal'lar Angular 16'da geldi (Angular serisinin "Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed" yazısı), resource API'si 19'da ("Angular 19: Varsayılan Standalone, linkedSignal ve resource() ile Veri Yükleme") ve Angular 22 varsayılan OnPush'u getirdi ("Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı"); bu serinin projesi de baştan zoneless kuruldu. Liste bir şeyi açıkça gösteriyor: klasik Store'un API'si yıllar içinde sadeleşti (creator fonksiyonları, createFeature, functional effect'ler) ve Signal Store, Angular signal'ları stable olunca bu sadeleşmenin bir sonraki adımı olarak geldi. Klasik Store bırakılmadı; NgRx 22'de de yeni özellik aldı.

Üretimde Dikkat

Araç seçimini bir kez yapıp unutma; feature büyüdükçe bir üst satıra geçmek normal. Bookstore'un sepeti bunun örneği: ikinci yazıda bir @Service() idi, beşinci yazıda Signal Store oldu, altıncıda entity'lere geçti, yedincide event'lere tepki vermeye başladı. Her adımda component kodu neredeyse hiç değişmedi, çünkü sepetin public API'si (items(), count(), add()) aynı kaldı. Store'u bir arayüz gibi tasarla: ne açtığın, içini ne kadar değiştirebileceğini belirliyor.

Ters yönde de dikkat: bir feature'a gereğinden ağır bir araç eklemek geri alınması zor bir karar. Events plugin ya da klasik Store'a geçen bir feature, onu kullanan her component'i, testleri ve ekibin alışkanlıklarını etkiliyor. Bir üst satıra geçmeden önce somut bir sorun göster: "üç store aynı olaya tepki veriyor ve her yeni tepki checkout'u değiştiriyor" gibi.

Topluluk paketlerini (NgRx Toolkit, TanStack Query adaptörü) eklemeden önce sürüm takibine bak: Angular ve NgRx her major sürümde peer dependency'lerini yükseltiyor ve topluluk paketi geride kalırsa yükseltmeni o paket tutuyor.

Sık Düşülen Tuzaklar

Aracı ekipten değil ihtiyaçtan seçmemek

"Büyük uygulama, o zaman klasik Store" ya da "yeni proje, o zaman her şey Signal Store" gibi genel kurallar, state'in türüne bakmadan karar veriyor. Bookstore gibi tek bir uygulamada bile dokuz feature için beş farklı araç doğru çıktı.

Server state'i store'a kopyalamak

Serinin ilk yazısındaki uyarı son yazıda da en sık hata: API'den gelen listeyi bir store'a koyup her mutation'dan sonra elle senkron tutmak. Önce resource; store'u, gerçekten client'a ait state için kullan.

God store

Her yeni ihtiyaçta mevcut bir store'a alan eklemek kolay. Bir store'un dosyası birden çok feature'ın diliyle konuşmaya başladıysa (sepet store'unda arama sonuçları gibi) onu böl.

DevTools'u üretime açık bırakmak

Sekizinci yazıdaki logOnly ve sanitizer'lar, toolkit için withDevToolsStub: state'te kullanıcıya ait veri varsa eklentisi olan herkes onu görebiliyor.

Küçük Bir Deneme

★☆☆ "Bookstore'un Feature'ları İçin Seçimler" tablosuna kendi uygulamandan üç feature ekle: state türü, seçtiğin araç ve tek cümlelik gerekçe. Hangisinde bir üst ya da alt satıra geçmek daha doğru olurdu?

★★☆ Back office route'unu loadChildren ile ayrı bir routes dosyasına taşı (provideState ve provideEffects o dosyada kalsın) ve "Maliyet: Bundle Boyutu" bölümündeki ölçümü tekrarla. Initial bundle ne kadar küçüldü, lazy chunk ne kadar?

★★★ Kullanıcı çıkış yaptığında bütün kullanıcıya özel state'i sıfırlayan bir akış tasarla: bir userLoggedOut event'i, sepet ve siparişler store'larında birer reducer, back office için klasik Store'da bir meta-reducer. Bunu bir event yerine her store'da bir clear() çağırarak yapmakla karşılaştır ve dokuzuncu yazıdaki gibi testini yaz.

Seri burada bitiyor. State'in türünü belirlemekle başladık ve her yazıda aynı soruya döndük: bu state kimin, kim okuyor, ne kadar yaşıyor? Bu soruların cevabı netse araç seçimi çoğu zaman kendiliğinden ortaya çıkıyor. Authentication state'i (guard, interceptor, token yenileme) bu serinin dışında kaldı; Authentication ve Authorization serisinin "Angular'da Authentication State: Functional Guard'lar, Interceptor, Tek Seferlik Token Refresh ve UI Yetkilendirmesi" yazısı oradan devam ediyor.