NgRx Signal Store'a Giriş: signalStore, withState, withComputed, withMethods ve patchState
NgRx Signal Store, elle yazılmış signal store'un parçalarını hazır feature'larla verir: withState state signal'larını, withComputed türetilmiş değerleri, withMethods ve patchState immutable güncellemeyi, withProps ve withHooks bağımlılıkları ve yaşam döngüsünü. Bookstore'un sepetini component kodu değişmeden çeviriyor; korumalı state'i, _ ile private üyeleri, NgRx 22'nin union DeepSignal'ını, Map tuzağını ve kalıcı state biçimi değişince kaybolan sepeti gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, NgRx 22.0.1 (@ngrx/signals), 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 artık bir NgRx Signal Store. Sepet sayfasına "Empty cart" butonu eklemek isteyen bir ekip arkadaşı component'e şu method'u yazıyor; patchState, Signal Store'un state güncelleme fonksiyonu:
emptyCart(): void {
patchState(this.cart, { items: [] });
localStorage.removeItem(this.cart._storageKey);
console.log(this.cart.coupon.code());
}
Üç satırın her biri, ikinci yazıdaki elle yazılmış store'da bir karşılığı olan bir şeyi deniyor: state'i dışarıdan değiştirmek, store'un bir iç detayını okumak ve iç içe bir state'in alanına ulaşmak. ng build bu method'u derler mi? Derlemezse hangi satırlar ve neden? Tahminini yaz; derleyicinin cevabı "Korumalı State" bölümünde.
Blok A'da state'in türlerini ve Angular'ın kendi araçlarını kullandık. Bu yazıyla NgRx'e geçiyoruz: ikinci yazıda @Service() ile elle yazdığımız CartStore'u ve checkout store'unu NgRx Signal Store'a çeviriyoruz. Odak, signalStore, withState, withComputed, withMethods, patchState, withProps ve withHooks'un elle yazılmış hâlin hangi parçasının yerini aldığı; küçük state için signalState; ve Signal Store'un ne zaman gereksiz olduğu. Entity'ler, rxMethod ve custom feature'lar bir sonraki yazıda.
Elle Yazılmış Store'dan signalStore'a
Paketi kurmak tek komut (@ngrx/signals resource extension'ları için üçüncü yazıdan beri projede):
npm install @ngrx/signals
Sepetin Signal Store hâli, kupon desteğiyle birlikte:
import { HttpClient } from '@angular/common/http';
import { computed, effect, inject } from '@angular/core';
import {
getState,
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { firstValueFrom } from 'rxjs';
import { apiUrl } from '../api';
import type { Book, CartItem, Coupon } from '../models';
type CartState = {
items: CartItem[];
coupon: Coupon | null;
};
const initialState: CartState = { items: [], coupon: null };
// Global client state: bütün uygulama için tek instance (root injector)
export const CartStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
// Bağımlılıklar ve sabitler; _ öneki onları store'a private yapar
withProps(() => ({
_http: inject(HttpClient),
_storageKey: 'bookstore.cart',
})),
withComputed(({ items }) => ({
count: computed(() => items().reduce((sum, item) => sum + item.quantity, 0)),
totalPrice: computed(() => items().reduce((sum, item) => sum + item.price * item.quantity, 0)),
})),
withComputed(({ totalPrice, coupon }) => ({
discount: computed(() => {
const c = coupon();
return c ? Math.round(totalPrice() * c.percent) / 100 : 0;
}),
})),
withComputed(({ totalPrice, discount }) => ({
payable: computed(() => totalPrice() - discount()),
})),
withMethods((store) => ({
add(book: Book): void {
patchState(store, ({ items }) => ({
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 {
patchState(store, ({ items }) => ({
items:
quantity < 1
? items.filter((item) => item.isbn !== isbn)
: items.map((item) => (item.isbn === isbn ? { ...item, quantity } : item)),
}));
},
remove(isbn: string): void {
patchState(store, ({ items }) => ({ items: items.filter((item) => item.isbn !== isbn) }));
},
clear(): void {
patchState(store, initialState);
},
async applyCoupon(code: string): Promise<boolean> {
try {
const url = `${apiUrl}/coupons/${encodeURIComponent(code)}`;
const coupon = await firstValueFrom(store._http.get<Coupon>(url));
patchState(store, { coupon });
return true;
} catch {
patchState(store, { coupon: null });
return false;
}
},
})),
withHooks({
onInit(store) {
// Bir kez localStorage'dan yükle, sonra her state değişikliğini kaydet (yalnız dışarıya senkronizasyon)
patchState(store, readSavedCart(store._storageKey));
effect(() => localStorage.setItem(store._storageKey, JSON.stringify(getState(store))));
},
}),
);
function readSavedCart(key: string): Partial<CartState> {
try {
const saved = JSON.parse(localStorage.getItem(key) ?? '{}') as Partial<CartState>;
return Array.isArray(saved.items) ? { items: saved.items, coupon: saved.coupon ?? null } : {};
} catch {
return {}; // storage engellenmiş ya da JSON bozuk: boş sepetle başla
}
}
signalStore bir class döndürüyor ve bu class bir service gibi inject ediliyor; yani CartStore hâlâ inject(CartStore) ile alınıyor. İçerik, sırayla uygulanan feature'lardan oluşuyor; her feature öncekilerin eklediği her şeyi görüyor. Elle yazılmış store'un parçalarıyla eşleşmesi:
| Elle yazılmış store (02) | Signal Store (05) | Not |
|---|---|---|
@Service() | { providedIn: 'root' } | Signal Store varsayılan olarak hiçbir injector'a kayıtlı değil |
#items = signal(...) | withState(initialState) | Her state alanı için bir signal üretiliyor |
items = this.#items.asReadonly() | Kendiliğinden | State dışarıya salt okunur açılıyor (protectedState) |
count = computed(...) | withComputed(...) | Aynı computed; düz bir fonksiyon da kabul ediliyor |
add(), this.#items.update(...) | withMethods(...), patchState(...) | Güncelleme yine immutable |
readonly #http = inject(...) | withProps(() => ({ _http: inject(...) })) | _ öneki private yapıyor |
Constructor'daki effect | withHooks({ onInit }) | Injection context içinde çalışıyor |
NgRx dokümantasyonunun bir notu önemli: Signal Store, providedIn: 'root' verilmedikçe hiçbir yerde sağlanmıyor ve bir providers listesine eklenmesi gerekiyor. Bu, ikinci yazıdaki @Service({ autoProvided: false }) ile aynı varsayılan; checkout store'u bu yüzden providedIn olmadan yazıldı.
withState, withComputed, withMethods ve patchState
withState, state'in başlangıç değerini alıyor ve her alanı için bir signal üretiyor: store.items(), store.coupon(). NgRx'in state tipi bir object literal (record) olmalı; alanlar array, primitive ya da iç içe object olabilir. İç içe object'ler için üretilen signal bir DeepSignal: hem kendisi okunabiliyor, hem de alt alanları için ayrı signal'ları var (store.address.city() gibi). Bu signal'lar ilk erişimde, ihtiyaç oldukça oluşturuluyor.
NgRx 22 burada bir breaking change getirdi. coupon'un tipi Coupon | null bir union. NgRx 21'e kadar böyle bir alan tek bir Signal<Coupon | null> idi; NgRx 22'de union içindeki her object literal için ayrı bir DeepSignal üretiliyor ve tip DeepSignal<Coupon> | Signal<null> oluyor. Pratikte store.coupon() eskisi gibi çalışıyor; store.coupon.code() ise ancak 'code' in store.coupon gibi bir daraltmadan sonra yazılabiliyor. Bunun derleme zamanında ne anlama geldiğini aşağıda göreceğiz.
withComputed, öncekilerin eklediği signal'ları alıp yeni türetilmiş signal'lar döndürüyor. discount, totalPrice'a ihtiyaç duyduğu için ikinci bir withComputed'da; aynı feature'ın içindeki bir computed'a başka bir computed'ın erişmesi için NgRx dokümantasyonu yardımcı bir değişken ya da ayrı bir feature öneriyor. withMethods state'i değiştiren method'ları tanımlıyor ve tek yazma yolu patchState. İki biçimi var: kısmi bir object (patchState(store, { coupon })) ya da mevcut state'i alıp kısmi bir state döndüren bir updater (patchState(store, ({ items }) => ({ items: [...] }))). İkisinde de güncelleme immutable olmalı; ikinci yazıdaki "aynı referansı döndürme" kuralı burada da geçerli ve aşağıdaki tuzaklardan biri bunu gösteriyor.
applyCoupon async bir method: kupon kodunu API'ye soruyor, geçerliyse state'e yazıyor. Async iş için Promise kullanmak Signal Store'da tamamen geçerli; aynı istek akışını RxJS operator'leriyle kurmak isteyen için rxMethod bir sonraki yazıda.
withProps, withHooks ve Checkout Store
withProps, state olmayan her şeyi store'a ekliyor: inject edilen bağımlılıklar, sabitler, Observable'lar. _ ile başlayan üyeler NgRx'te private: store'un kendi feature'ları onları görüyor, dışarıdan erişilemiyor. withHooks, store oluşturulduğunda (onInit) ve destroy edildiğinde (onDestroy) çalışan kodu tanımlıyor; onInit injection context içinde çalıştığı için içinde effect oluşturulabiliyor. Sepette bunu kalıcılık için kullanıyoruz: bir kez localStorage'dan oku, sonra her değişiklikte yaz. getState(store) bir reactive context içinde okunduğunda state'in tamamını takip ediyor.
Checkout store'u da aynı şekilde çevrildi:
import { computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withHooks, withMethods, withProps, withState } from '@ngrx/signals';
import { CartStore } from '../cart/cart-store';
export type ShippingMethod = 'standard' | 'express';
let instances = 0;
// Feature state: providedIn yok; 'checkout' route'u kendi providers listesinde verir
export const CheckoutStore = signalStore(
withState({ shipping: 'standard' as ShippingMethod }),
withProps(() => ({ _cart: inject(CartStore), _instance: ++instances })),
withComputed(({ shipping, _cart }) => ({
shippingPrice: computed(() => (shipping() === 'express' ? 90 : _cart.payable() >= 500 ? 0 : 40)),
})),
withComputed(({ shippingPrice, _cart }) => ({
payable: computed(() => _cart.payable() + shippingPrice()),
})),
withMethods((store) => ({
chooseShipping(shipping: ShippingMethod): void {
patchState(store, { shipping });
},
})),
withHooks({
onInit: ({ _instance }) => console.log(`[checkout] store #${_instance} created`),
onDestroy: ({ _instance }) => console.log(`[checkout] store #${_instance} destroyed`),
}),
);
Bağımlılığı (_cart) ve instance numarası withProps'ta; created/destroyed log'ları withHooks'ta. Route tanımı hiç değişmedi: providers: [CheckoutStore]. Kargo bedelinin hesabı artık kupon indirimli tutarı (_cart.payable()) kullanıyor.
Component Kodu Değişmedi
Çevirinin en iyi tarafı, store'u kullanan component'lerde neredeyse hiçbir şeyin değişmemesi. Header, katalog, sepet ve checkout sayfaları inject(CartStore) ve cart.count(), cart.add(book), cart.setQuantity(...) gibi aynı isimleri kullanıyor; bu bilerek seçilmiş bir API. Sepet sayfasına yalnız kupon alanı eklendi:
<p>
<input class="coupon-code" placeholder="Coupon" #code />
<button class="apply" (click)="cart.applyCoupon(code.value)">Apply</button>
</p>
@if (cart.coupon(); as coupon) {
<p class="discount">{{ coupon.code }}: -{{ cart.discount() }} TRY</p>
}
<p class="total">Total: {{ cart.payable() }} TRY</p>
İkinci yazıdaki kalıcılık senaryosu, değişmeden aynı Playwright script'iyle:
after adding : Cart (3)
after reload : Cart (3)
cart page : Total: 1220 TRY
localStorage : {"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":null}
localStorage'daki biçim değişti: artık bir array değil, getState'in döndürdüğü state object'i. Kupon senaryosu (geçersiz bir kod, geçerli bir kod, sonra sayfa yenileme):
cart - | Total: 770 TRY
2.3 s console: Failed to load resource: the server responded with a status of 404 (Not Found)
apply FREE50 - | Total: 770 TRY
apply BOOKWEEK BOOKWEEK: -77 TRY | Total: 693 TRY
after reload BOOKWEEK: -77 TRY | Total: 693 TRY
localStorage: {"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":1},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":{"code":"BOOKWEEK","percent":10}}
Geçersiz kod 404 aldı ve state'e hiçbir şey yazılmadı; BOOKWEEK 770 TRY'ye (Türk lirası) %10 indirim uyguladı ve kupon da state'in parçası olduğu için yenilemeden sonra yerinde. Checkout'un provider scope senaryosu da ikinci yazıdakiyle aynı sonucu veriyor; log'lar bu kez withHooks'tan geliyor:
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
Korumalı State, Private Üyeler ve Union DeepSignal
"Önce Tahmin Et"teki method'un derleme sonucu:
✘ [ERROR] TS2345: Argument of type '{ items: Signal<CartItem[]>; coupon: DeepSignal<Coupon> | Signal<null>; count: Signal<number>; totalPrice: Signal<number>; ... 6 more ...; applyCoupon: (code: string) => Promise<...>; } & StateSource<...>' is not assignable to parameter of type 'WritableStateSource<{ items: CartItem[]; coupon: Coupon | null; }>'.
The types of '[STATE_SOURCE].items' are incompatible between these types.
Type 'Signal<CartItem[]>' is missing the following properties from type 'WritableSignal<CartItem[]>': set, update, asReadonly, [ɵWRITABLE_SIGNAL] [plugin angular-compiler]
src/app/cart/cart-page.ts:40:15:
40 │ patchState(this.cart, { items: [] });
╵ ~~~~~~~~~
✘ [ERROR] TS2339: Property '_storageKey' does not exist on type '{ items: Signal<CartItem[]>; coupon: DeepSignal<Coupon> | Signal<null>; count: Signal<number>; totalPrice: Signal<number>; ... 6 more ...; applyCoupon: (code: string) => Promise<...>; } & StateSource<...>'. [plugin angular-compiler]
src/app/cart/cart-page.ts:41:38:
41 │ localStorage.removeItem(this.cart._storageKey);
╵ ~~~~~~~~~~~
✘ [ERROR] TS2339: Property 'code' does not exist on type 'DeepSignal<Coupon> | Signal<null>'.
Property 'code' does not exist on type 'Signal<null>'. [plugin angular-compiler]
src/app/cart/cart-page.ts:42:33:
42 │ console.log(this.cart.coupon.code());
╵ ~~~~
Üç satır, üç hata. Birincisi Signal Store'un protectedState davranışı: NgRx dokümantasyonuna göre state varsayılan olarak dışarıdan değiştirilmeye kapalı. Store'un dışından patchState çağırmak, state signal'larının WritableSignal değil Signal olması yüzünden tip hatası veriyor; ikinci yazıdaki asReadonly()'nin karşılığı, ama ek kod yazmadan. signalStore({ protectedState: false }, ...) bu korumayı kaldırıyor; dokümantasyon da önermiyor, biz de. Doğru çözüm store'daki clear().
İkincisi _ önekli üyelerin dışarıya hiç açılmaması: _storageKey store'un tipinde yok. Üçüncüsü NgRx 22'nin union değişikliği: coupon Signal<null> da olabildiği için .code doğrudan erişilemiyor. Template'te @if (cart.coupon(); as coupon) ile değeri okuyup daraltmak en sade yol.
signalState: Küçük State İçin
Her state bir store gerektirmiyor. Kitap detay sayfasındaki yorum formunun üç signal'ı vardı (text, saving, saveError). NgRx'in signalState'i, injector'a, provider'a ve feature'lara ihtiyaç duymadan aynı state'i tek bir object'te topluyor:
// Küçük local state: üç ayrı signal yerine tek bir signalState
protected readonly form = signalState({ text: '', saving: false, error: null as string | null });
constructor() {
effect(() => console.log(`[book] ${this.book.status()} | [related] ${this.related.status()}`));
effect(() => console.log(`[reviews] ${this.reviews.status()}: ${this.reviews.value().length} reviews`));
}
protected setText(text: string): void {
patchState(this.form, { text });
}
signalState bir signal döndürüyor (form() state'in tamamı) ve her alan için ayrı signal'lar üretiyor (form.text()); güncelleme yine patchState ile. Component içinde tanımlandığı için ömrü component'in ömrü; bu, ilk yazıdaki "local UI state" tanımının ta kendisi. Üç ayrı signal da yanlış değildi; signalState'in kazancı, birbirine ait alanları tek bir patchState çağrısıyla birlikte güncelleyebilmek ({ saving: true, error: null }). Yorum ekleme senaryosu bu değişiklikten sonra da üçüncü yazıdaki çıktının aynısını verdi.
Üretimde Dikkat
Kalıcı state'in biçimi bir sözleşme. Önceki yazılardaki sepet localStorage'a bir array yazıyordu; Signal Store sürümü bir object yazıyor. Bu yazının kodu, eski biçimde kayıtlı bir sepet bulunan bir tarayıcıda açıldığında:
header : Cart (0)
localStorage: {"items":[],"coupon":null}
Kullanıcının sepetindeki iki Dune sessizce kayboldu: readSavedCart eski biçimi tanımadı, boş sepete düştü ve effect boş state'i kaydederek eski veriyi sildi. Kod tek başına doğru davranıyor (bozuk veriyle açılmıyor), ama gerçek bir release'te bu kullanıcıya sepet kaybı olarak yansır. Kalıcı state'in biçimini değiştirirken bir sürüm alanı ekle ({ version: 2, items, coupon }), eski biçimi okuyup yenisine çeviren küçük bir migration yaz ve bunu bir test ile koru.
Root'ta sağlanan bir Signal Store, ikinci yazıdaki root service ile aynı ömre sahip: kullanıcı çıkış yaptığında kendiliğinden sıfırlanmıyor. clear() state'i initialState'e döndürüyor ve effect bunu localStorage'a da yazıyor; çıkış akışı bu method'u çağırmalı.
Signal Store'da Redux DevTools ile state'i izlemek resmî NgRx paketlerinde yok; NgRx dokümantasyonu bunun için topluluk paketi @angular-architects/ngrx-toolkit'teki withDevtools'u gösteriyor (npm'de 22.0.0). Bu seride kullanmadık; araç seçimi yazısında ele alınacak.
Sık Düşülen Tuzaklar
State'e Map ya da class instance koymak
NgRx dokümantasyonu state'in bir record olmasını ve güncellemelerin immutable yapılmasını istiyor. Bir Map'i state'e koyup yerinde değiştirmek derleyiciden geçiyor ama değişiklik algılanmıyor. Uygulama açılırken tarayıcıda çalıştırılan küçük bir deneme:
import { computed } from '@angular/core';
import { patchState, signalState } from '@ngrx/signals';
export function runMapDemo(): void {
// yanlış: yerinde değiştirilen bir Map; state slice'ı aynı referansı tutuyor
const withMap = signalState({ quantities: new Map<string, number>() });
const mapCount = computed(() => [...withMap.quantities().values()].reduce((a, b) => a + b, 0));
console.log('[demo] Map count before:', mapCount());
patchState(withMap, ({ quantities }) => {
quantities.set('9780441172719', 2);
return { quantities };
});
console.log('[demo] Map size after:', withMap.quantities().size, '| count after:', mapCount());
// doğru: düz bir record, yeni bir object ile değiştiriliyor
const withRecord = signalState({ quantities: {} as Record<string, number> });
const recordCount = computed(() => Object.values(withRecord.quantities()).reduce((a, b) => a + b, 0));
console.log('[demo] Record count before:', recordCount());
patchState(withRecord, ({ quantities }) => ({ quantities: { ...quantities, '9780441172719': 2 } }));
console.log('[demo] Record count after:', recordCount());
}
0.1 s console: [demo] Map count before: 0
0.1 s console: [demo] Map size after: 1 | count after: 0
0.1 s console: [demo] Record count before: 0
0.1 s console: [demo] Record count after: 2
Map'te bir eleman var ama ona bağlı computed hâlâ 0: patchState'e aynı Map referansı döndü ve signal açısından hiçbir şey değişmedi. Aynı veri düz bir record'da ve yeni bir object ile güncellendiğinde computed doğru değeri veriyor. Class instance'ları (ör. Date ya da kendi domain class'ların) için de aynı kural geçerli: yerinde değiştirme yerine yenisini üret, mümkünse state'te düz veri tut.
Her store'u root'ta sağlamak
{ providedIn: 'root' } yazmak kolay, ama ilk yazıdaki kapsam kuralı burada da geçerli. Checkout store'u root'ta olsaydı ikinci yazıdaki "express seçimi geri geldi" çıktısını withAutoCleanupInjectors() bile düzeltemezdi. Root'u, uygulama boyunca gerçekten tek kopyası olması gereken state'e (sepet, oturum) bırak; feature state'i route'un, local state'i component'in providers'ında sağla.
Tek component'in birkaç signal'ı için Signal Store eklemek
Bir dialog'un açık olup olmadığını ve seçili sekmeyi tutmak için signalStore, withState ve withMethods yazmak, iki signal'ın işini bir store dosyası, bir provider ve bir test kurulumuna çeviriyor. Bu ölçekte düz signal ya da signalState yeterli; Signal Store, aynı state'i birden fazla component paylaştığında, method'lar iş kuralı taşıdığında ya da store'u test etmek istediğinde kendini geri ödüyor.
Store'un içinde state'i elle kopyalamak
withComputed yerine bir withHooks + effect ile patchState(store, { count: ... }) yazmak, ikinci yazıdaki "derived değeri effect ile kopyalamak" tuzağının Signal Store'daki hâli. Türetilebilen her şey withComputed'da.
Küçük Bir Deneme
★☆☆ Sepet sayfasındaki kupon kutusunun yanına bir "Remove coupon" butonu ekle. Store'a hangi method'u eklemen gerekiyor ve neden component'ten patchState çağıramıyorsun?
★★☆ Kalıcı sepet için bir sürüm alanı ve migration yaz: readSavedCart eski array biçimini tanıyıp { items, coupon: null }'a çevirsin. "Üretimde Dikkat"teki Playwright senaryosunu tekrar çalıştırıp iki Dune'un kaybolmadığını doğrula.
★★★ Katalogdaki kapak dialog'unu ve görünüm tercihini (liste ya da ızgara) bir signalState'e taşı; sonra aynı state'i component seviyesinde sağlanan küçük bir Signal Store (providers: [CatalogViewStore]) olarak yaz. İki sürümü kod satırı, test edilebilirlik ve kategori değişince dialog'un kapanması (linkedSignal'ın yaptığı iş) açısından karşılaştır.
Bir sonraki yazıda Signal Store'un ileri seviye parçalarına geçiyoruz: withEntities ile sepet satırlarını entity koleksiyonuna taşımak, rxMethod ve signalMethod ile async iş, withLinkedState ve her store'a eklenebilen bir withRequestStatus() custom feature'ı.