Signal Store İleri Seviye: withEntities, rxMethod, signalMethod, withLinkedState ve Custom Store Feature'ları
Signal Store'un ileri seviye parçaları tekrar eden işi store'dan çıkarır: withEntities koleksiyonları updater'larla yönetir, rxMethod RxJS ile iptal edilebilir async iş yapar, signalMethod aynısını RxJS'siz yapar, custom feature'lar her store'a eklenir. Bookstore'un sepetini entity'lere taşıyor, hızlı aramayı rxMethod ile yazıyor, withRequestStatus() ve SignalStoreFeatureType ile tipli bir log feature'ı kuruyor, tapResponse'suz bir hatanın stream'i bitirdiğini gerçek çıktıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, NgRx 22.0.1 (@ngrx/signals, @ngrx/operators), 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 satırlarını NgRx'in entity koleksiyonuna taşıyoruz. Satırın kimliği ISBN (kitabın uluslararası standart numarası). Bir ekip arkadaşı add method'unu şöyle sadeleştiriyor; addEntity, @ngrx/signals/entities'in koleksiyona eleman ekleyen updater'ı:
add(book: Book): void {
patchState(store, addEntity({ isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }, lineConfig));
},
Katalogda Dune'a iki kez, Emma'ya bir kez "Add to cart" tıklanıyor. Header'daki sayaç kaç gösterir: 3 mü, 2 mi? Sepet sayfasında Dune satırının adedi kaç olur ve tarayıcı console'unda bir hata görür müsün? Tahminini yaz; cevap "withEntities" bölümünde.
Önceki yazıda elle yazılmış sepeti ve checkout store'unu NgRx Signal Store'a çevirdik. Bu yazıda Signal Store'un ileri seviye parçalarını Bookstore'a ekliyoruz: sepet satırları için withEntities, header'da bir hızlı arama kutusu için rxMethod, "son bakılan kitaplar" listesi için signalMethod, ve her store'a eklenebilen iki custom feature: withRequestStatus() ve onu girdi olarak isteyen withRequestLog(). Son olarak withFeature ve withLinkedState ile klavyeyle seçim yapan, tekrar kullanılabilir bir feature yazıyoruz.
withEntities: Sepet Satırları Bir Koleksiyonda
Sepetin bu yazı sonundaki hâli:
import { HttpClient } from '@angular/common/http';
import { computed, effect, inject } from '@angular/core';
import { patchState, signalStore, type, withComputed, withHooks, withMethods, withProps, withState } from '@ngrx/signals';
import {
addEntity,
entityConfig,
removeAllEntities,
removeEntity,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';
import { apiUrl } from '../api';
import type { Book, CartItem, Coupon } from '../models';
import { withRequestLog } from '../shared/with-request-log';
import { setError, setFulfilled, setPending, withRequestStatus } from '../shared/with-request-status';
// Sepet satırları bir entity koleksiyonu: kimlik ISBN, '_' koleksiyonu private yapar
const lineConfig = entityConfig({
entity: type<CartItem>(),
collection: '_line',
selectId: (line) => line.isbn,
});
export const CartStore = signalStore(
{ providedIn: 'root' },
withEntities(lineConfig),
withState({ coupon: null as Coupon | null }),
withRequestStatus(),
withRequestLog('cart'),
withProps(() => ({
_http: inject(HttpClient),
_storageKey: 'bookstore.cart',
})),
withComputed(({ _lineEntities }) => ({
// public API aynı kalır: component'ler hâlâ cart.items() okur
items: _lineEntities,
count: computed(() => _lineEntities().reduce((sum, line) => sum + line.quantity, 0)),
totalPrice: computed(() => _lineEntities().reduce((sum, line) => sum + line.price * line.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 {
// addEntity var olan bir kimliği atlar; bu yüzden mevcut satır güncellenir
patchState(
store,
store._lineEntityMap()[book.isbn]
? updateEntity({ id: book.isbn, changes: (line) => ({ quantity: line.quantity + 1 }) }, lineConfig)
: addEntity({ isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }, lineConfig),
);
},
setQuantity(isbn: string, quantity: number): void {
patchState(
store,
quantity < 1 ? removeEntity(isbn, lineConfig) : updateEntity({ id: isbn, changes: { quantity } }, lineConfig),
);
},
remove(isbn: string): void {
patchState(store, removeEntity(isbn, lineConfig));
},
clear(): void {
patchState(store, removeAllEntities(lineConfig), { coupon: null });
},
async applyCoupon(code: string): Promise<void> {
patchState(store, setPending());
try {
const url = `${apiUrl}/coupons/${encodeURIComponent(code)}`;
const coupon = await firstValueFrom(store._http.get<Coupon>(url));
patchState(store, { coupon }, setFulfilled());
} catch (error) {
patchState(store, { coupon: null }, setError(error));
}
},
})),
withHooks({
onInit(store) {
const saved = readSavedCart(store._storageKey);
patchState(store, setAllEntities(saved.items, lineConfig), { coupon: saved.coupon });
// getState()'i değil, sepetin sürümlü bir kopyasını kaydet: entityMap, ids ve requestStatus iç detaylar
effect(() => {
const cart: SavedCart = { version: 2, items: store.items(), coupon: store.coupon() };
localStorage.setItem(store._storageKey, JSON.stringify(cart));
});
},
}),
);
type SavedCart = { version: 2; items: CartItem[]; coupon: Coupon | null };
// Uygulamanın bugüne kadar kaydettiği her biçimi okur: v0 array (01-04. yazılar), v1 object (05), v2 (06)
function readSavedCart(key: string): SavedCart {
const empty: SavedCart = { version: 2, items: [], coupon: null };
try {
const saved: unknown = JSON.parse(localStorage.getItem(key) ?? 'null');
if (Array.isArray(saved)) return { ...empty, items: saved as CartItem[] };
if (saved && typeof saved === 'object' && Array.isArray((saved as SavedCart).items)) {
const { items, coupon } = saved as SavedCart;
return { version: 2, items, coupon: coupon ?? null };
}
return empty;
} catch {
return empty; // storage engellenmiş ya da JSON bozuk: boş sepetle başla
}
}
withEntities, state'e iki alan ekliyor: ids (kimliklerin sırası) ve entityMap (kimlikten entity'ye bir record). entities ise bu ikisinden türetilen bir computed. Varsayılan olarak entity'nin id adlı bir property'si olması bekleniyor; bizim satırlarımızda kimlik isbn, bu yüzden entityConfig'e bir selectId verdik. collection: '_line' koleksiyona isim veriyor: alanlar _lineIds, _lineEntityMap ve _lineEntities oluyor ve _ öneki, önceki yazıdaki private üyeler gibi, onları store'un dışına kapatıyor. Dışarıya items: _lineEntities ile salt okunur bir görünüm açtık; böylece header, sepet ve checkout component'leri hâlâ cart.items() okuyor ve hiçbiri değişmedi.
Güncellemeler entity updater'larıyla yapılıyor: addEntity, updateEntity, removeEntity, setAllEntities, removeAllEntities. Hepsi patchState'e verilen sıradan updater'lar; aynı çağrıda başka bir kısmi state ile birleştirilebiliyorlar (clear()'daki { coupon: null } gibi). Bir detay: selectId'i add*, set* ve update* updater'larına config ile vermek gerekiyor, remove* updater'ları ise kimliği doğrudan alıyor.
"Önce Tahmin Et"teki sadeleştirilmiş add ile aynı tıklamalar:
header : Cart (2)
cart lines : Dune x 1 = 450 TRY | Emma x 1 = 320 TRY
localStorage: {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":1},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":null}
Sayaç 2, Dune'un adedi 1 ve console'da hiçbir hata yok. NgRx dokümantasyonu bunu açıkça yazıyor: aynı kimlikte bir entity zaten varsa addEntity onu ezmiyor ve hata da fırlatmıyor. Bu, bir koleksiyon için doğru davranış (aynı kaydı iki kez eklemek çoğu zaman bir hata), ama sepet için "bir tane daha" demek. Bu yüzden asıl koddaki add, satır varsa updateEntity'yi bir updater fonksiyonuyla (quantity + 1), yoksa addEntity'yi kullanıyor:
header : Cart (3)
cart lines : Dune x 2 = 900 TRY | Emma x 1 = 320 TRY
localStorage: {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":null}
Var olanı birleştirerek güncelleyen upsertEntity da var, ama o, gelen object'in alanlarını eskisinin üstüne yazıyor; adedi artırmak için mevcut değeri okuman gerekiyor, bu da updateEntity'nin updater biçimi.
Named Collection ve signalMethod: Son Bakılan Kitaplar
Kullanıcının detayına baktığı son üç kitabı katalogda göstermek istiyoruz. Bu, root'ta yaşayan küçük bir client state:
import { patchState, signalMethod, signalStore, type, withMethods } from '@ngrx/signals';
import { entityConfig, prependEntity, removeEntities, removeEntity, withEntities } from '@ngrx/signals/entities';
import type { Book } from '../models';
// Named collection: entities, ids ve entityMap yerine viewedEntities, viewedIds ve viewedEntityMap
const viewedConfig = entityConfig({
entity: type<Book>(),
collection: 'viewed',
selectId: (book) => book.isbn,
});
export const RecentlyViewedStore = signalStore(
{ providedIn: 'root' },
withEntities(viewedConfig),
withMethods((store) => ({
// Bir değer, bir signal ya da bir computation alır; senkron bir güncelleme RxJS gerektirmez
track: signalMethod<Book | undefined>((book) => {
if (!book) return;
patchState(store, removeEntity(book.isbn, viewedConfig), prependEntity(book, viewedConfig));
patchState(store, removeEntities(store.viewedIds().slice(3), viewedConfig));
}),
})),
);
Bu kez koleksiyonun adı public: viewedEntities, viewedIds, viewedEntityMap. Aynı store'da farklı isimlerle birden fazla withEntities kullanılabiliyor; NgRx dokümantasyonu yine de çoğu durumda her entity tipi için ayrı bir store öneriyor. prependEntity kitabı listenin başına koyuyor; zaten varsa eklemiyor, bu yüzden önce removeEntity ile çıkarıp sonra başa ekliyoruz. Üçten fazlası removeEntities ile atılıyor.
track bir signalMethod. Bir değer, bir signal ya da bir computation fonksiyonu alabiliyor; signal verildiğinde değer her değiştiğinde çalışıyor. Kitap detay sayfası onu constructor'da şöyle çağırıyor:
// signalMethod bir computation ile, component'in injection context'inde çağrılır: sayfayla birlikte temizlenir
inject(RecentlyViewedStore).track(() => (this.book.hasValue() ? this.book.value() : undefined));
İçeride bir effect var, ama iki farkla: yalnız verilen computation takip ediliyor (method'un gövdesi untracked çalışıyor, gövdede okunan store.viewedIds() yeni bir bağımlılık yaratmıyor) ve aynı method farklı girdilerle birden çok kez çağrılabiliyor. Store root'ta, ama çağrı component'in injection context'inde yapıldığı için içteki effect sayfa destroy edildiğinde temizleniyor. NgRx dokümantasyonu, bir signal ile injection context dışında ve injector vermeden çağırmanın deprecated olduğunu ve ileride hata fırlatacağını söylüyor; root bir store'un method'unu ngOnInit'ten signal ile çağırıyorsan { injector } ver, yoksa effect component'ten uzun yaşıyor.
rxMethod: Header'da Hızlı Arama
Header'daki arama kutusu yazıldıkça öneri gösteriyor. Burada RxJS gerçekten işe yarıyor: yazmada bir duraklama beklemek, aynı sorguyu tekrar göndermemek ve artık gerekmeyen isteği iptal etmek.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { patchState, signalStore, withFeature, withHooks, withMethods, withProps, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { debounceTime, distinctUntilChanged, EMPTY, map, pipe, switchMap } from 'rxjs';
import { apiUrl } from '../api';
import type { Book, BookPage } from '../models';
import { withKeyboardSelection } from '../shared/with-keyboard-selection';
import { withRequestLog } from '../shared/with-request-log';
import { setError, setFulfilled, setPending, withRequestStatus } from '../shared/with-request-status';
// Sunucu çağrısı yapan local UI state: arama kutusu component'i sağlar, her kutuya bir instance
export const SearchStore = signalStore(
withState({ query: '', results: [] as Book[] }),
withRequestStatus(),
withRequestLog('search'),
withFeature(({ results }) => withKeyboardSelection(results)),
withProps(() => ({ _http: inject(HttpClient) })),
withMethods((store) => ({
setQuery(query: string): void {
patchState(store, { query });
},
// RxJS'in işe yaradığı yer: yazmada bir duraklama bekle, artık gerekmeyen isteği iptal et
_search: rxMethod<string>(
pipe(
map((query) => query.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) {
patchState(store, { results: [], requestStatus: 'idle', requestError: null });
return EMPTY;
}
patchState(store, setPending());
return store._http.get<BookPage>(`${apiUrl}/books`, { params: { q: query } }).pipe(
// hatayı iç stream'de yakalar; dış stream çalışmaya devam eder
tapResponse({
next: (page) => patchState(store, { results: page.items }, setFulfilled()),
error: (error) => patchState(store, { results: [] }, setError(error)),
}),
);
}),
),
),
})),
withHooks({
// Signal ile çağrılır: her yeni sorgu stream'e girer; store'un injector'ıyla birlikte temizlenir
onInit: (store) => store._search(store.query),
}),
);
rxMethod<string> bir operator zinciri alıyor ve signalMethod gibi bir değer, bir signal, bir computation ya da bir Observable kabul eden bir method döndürüyor. onInit'te store.query signal'ı ile bir kez çağrılıyor; bundan sonra setQuery state'i değiştirdikçe yeni değer stream'e giriyor. debounceTime, distinctUntilChanged ve switchMap'in davranışı RxJS serisinin "Higher-order Mapping: switchMap, mergeMap, concatMap ve exhaustMap Arasında Doğru Seçim" ve "Zamanla Çalışan Operator'ler ve Scheduler'lar" yazılarında; burada önemli olan, isteğin sonucunu patchState ile store'a yazmak. _search private: component'ler yalnız setQuery'yi görüyor.
Store, arama kutusu component'inin kendi providers'ında:
@Component({
selector: 'app-search-box',
// Component seviyesinde store: bu component ile oluşur, onunla birlikte destroy edilir
providers: [SearchStore],
Önceki yazıda yalnız alıştırma olarak geçen component seviyesinde sağlanan store'un gerçek bir örneği bu: her arama kutusunun kendi state'i var ve kutu destroy edildiğinde store da, içindeki rxMethod subscription'ı da kapanıyor. Sepet sayfasında (katalog isteği olmasın diye) yapılan bir oturum:
0.1 s request: GET /categories
typed "d" - | -
1.3 s console: [search] idle -> pending
1.3 s request: GET /books?q=dun
1.6 s console: [search] pending -> fulfilled
typed "dun" [Dune] | -
2.3 s console: [search] fulfilled -> pending
2.3 s request: GET /books?q=the
2.6 s console: [search] pending -> fulfilled
replaced with "the" [The Hobbit] | -
3.3 s console: [search] fulfilled -> pending
3.3 s request: GET /books?q=em
3.6 s console: [search] pending -> fulfilled
replaced with "em" [Emma], Middlemarch | -
ArrowDown Emma, [Middlemarch] | -
4.6 s console: [search] fulfilled -> pending
4.6 s request: GET /books?q=mi
4.9 s console: [search] pending -> fulfilled
replaced with "mi" [Middlemarch] | -
5.2 s request: GET /books/9780141439549
5.2 s request: GET /books/9780141439549/reviews
5.5 s console: [search] fulfilled -> idle
5.5 s request: GET /books?category=classics
Enter -> /books/9780141439549 | Middlemarch
6.0 s request: GET /books/9780141439587
6.0 s request: GET /books/9780141439587/reviews
6.3 s request: GET /books?category=classics
related link -> Emma
6.8 s request: GET /books?page=1&sort=title
catalog: Recently viewed: Emma, Middlemarch
Tek harfte istek yok; "dun" üç tuşta yazıldı ama 300 ms beklemeden sonra tek bir GET /books?q=dun gitti. Köşeli parantez, klavyeyle seçili öneriyi gösteriyor; birazdan geleceğiz. Enter, seçili kitabın sayfasını açtı ve kutuyu temizledi (fulfilled -> idle). En alttaki satır signalMethod'un işi: iki detay sayfası gezildi, katalogda en yenisi başta.
tapResponse, @ngrx/operators'tan geliyor: başarı ve hata callback'lerini bir tap ile çalıştırıp hatayı catchError(() => EMPTY) ile yutuyor. Bu operator iç stream'de, yani switchMap'in döndürdüğü HTTP Observable'ında duruyor; nedeni "Sık Düşülen Tuzaklar"da. NgRx 22, tapResponse'un eski callback biçimini (tapResponse(next, error)) kaldırdı; yalnız object biçimi kaldı. Aynı paketteki mapResponse ise sonucu bir değere çeviriyor (başarıda bir değer, hatada başka bir değer) ve event ya da action üreten akışlar için; bir sonraki yazıda Events plugin'le kullanacağız.
signalMethod mı, rxMethod mı? NgRx dokümantasyonu signalMethod'u RxJS'siz bir rxMethod olarak tanımlıyor; bundle'a RxJS operator'leri eklemiyor. Senkron bir güncelleme (son bakılanlar) için o yeterli. Zamanlama, iptal ya da sıralama gerektiren async işlerde (arama, art arda gelen kaydetme istekleri) RxJS operator'leri belirgin şekilde daha güçlü: signal'lar senkron değişikliklerde yalnız son değeri iletiyor ve switchMap ya da concatMap gibi bir karşılıkları yok.
Custom Feature: withRequestStatus()
Hem arama hem kupon bir istek yapıyor ve ikisinde de aynı soruları soruyoruz: istek sürüyor mu, bitti mi, hata ne? Bunu her store'a tekrar yazmak yerine bir custom feature'a koyuyoruz:
import { HttpErrorResponse } from '@angular/common/http';
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState, type SignalStoreFeatureType } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | 'error';
export type RequestStatusState = { requestStatus: RequestStatus; requestError: string | null };
// Tekrar kullanılabilir feature: API ile konuşan her store aynı durum alanlarını alır
export function withRequestStatus() {
return signalStoreFeature(
withState<RequestStatusState>({ requestStatus: 'idle', requestError: null }),
withComputed(({ requestStatus }) => ({
isPending: computed(() => requestStatus() === 'pending'),
})),
);
}
// Updater'lar düz fonksiyon; bu yüzden tek bir patchState çağrısında başka updater'larla birleşirler
export function setPending(): RequestStatusState {
return { requestStatus: 'pending', requestError: null };
}
export function setFulfilled(): RequestStatusState {
return { requestStatus: 'fulfilled', requestError: null };
}
export function setError(error: unknown): RequestStatusState {
const message = error instanceof HttpErrorResponse ? `HTTP ${error.status}` : String(error);
return { requestStatus: 'error', requestError: message };
}
// NgRx 22: bu feature'ın eklediği üyeler; başka bir feature'ın girdi tipi olarak kullanılabilir
export type RequestStatusFeature = SignalStoreFeatureType<typeof withRequestStatus>;
signalStoreFeature, withState ve withComputed gibi feature'ları tek bir feature'da birleştiriyor; store'a eklendiğinde requestStatus, requestError ve isPending o store'un parçası oluyor. State'i değiştiren kısım feature'ın method'ları değil, düz fonksiyonlar: setPending(), setFulfilled(), setError(error). NgRx dokümantasyonu custom feature'larda bunu öneriyor; nedenleri tree-shaking, kolay test ve en önemlisi başka updater'larla tek bir patchState çağrısında birleşebilmesi. Arama store'undaki patchState(store, { results: page.items }, setFulfilled()) ve sepetteki patchState(store, { coupon }, setFulfilled()) bunun örneği: sonuç ve durum aynı anda değişiyor, arada "fulfilled ama sonuç yok" gibi bir ara state görünmüyor.
Plandaki durum listesi idle | pending | fulfilled | error; hata mesajını ayrı bir requestError alanında tuttuk. Mesajı union'ın içine koymak ({ error: string }) da mümkün, ama önceki yazıda gördüğümüz NgRx 22 değişikliği yüzünden object içeren bir union'ın signal'ı artık DeepSignal | Signal oluyor ve template'te daraltma gerektiriyor; düz string alanlar bu sorunu hiç çıkarmıyor.
Girdisi Olan Feature: SignalStoreFeatureType ve watchState
İkinci feature, requestStatus'taki her geçişi console'a yazıyor. Kendi başına çalışamaz; store'da withRequestStatus() olmasını bekliyor:
import { signalStoreFeature, type, watchState, withHooks } from '@ngrx/signals';
import type { RequestStatusFeature } from './with-request-status';
// Aynı store'da daha önce withRequestStatus() olmalı; derleyici bunu kontrol eder
export function withRequestLog(name: string) {
return signalStoreFeature(
type<RequestStatusFeature>(),
withHooks({
onInit(store) {
let previous = store.requestStatus();
// watchState senkron: bir tick'teki yalnız sonuncuyu değil, her patchState çağrısını görür
watchState(store, ({ requestStatus, requestError }) => {
if (requestStatus === previous) return;
console.log(`[${name}] ${previous} -> ${requestStatus}${requestError ? ` (${requestError})` : ''}`);
previous = requestStatus;
});
},
}),
);
}
signalStoreFeature'ın ilk argümanı feature'ın girdisi: "bu feature'ı kullanan store'da şu state, prop ve method'lar olmalı". NgRx 22 ile gelen SignalStoreFeatureType, bir feature fonksiyonunun eklediği her şeyin tipini çıkarıyor; type<RequestStatusFeature>() ile withRequestStatus()'un tipini elle tekrar yazmadan girdi olarak kullanıyoruz. Sepetteki kupon akışı bu iki feature ile:
cart - | - | Total: 770 TRY
2.1 s console: [cart] idle -> pending
2.4 s console: Failed to load resource: the server responded with a status of 404 (Not Found)
2.4 s console: [cart] pending -> error (HTTP 404)
apply FREE50 Coupon not applied (HTTP 404) | - | Total: 770 TRY
2.6 s console: [cart] error -> pending
3.0 s console: [cart] pending -> fulfilled
apply BOOKWEEK - | BOOKWEEK: -77 TRY | Total: 693 TRY
after reload - | BOOKWEEK: -77 TRY | Total: 693 TRY
Girdi bir sözleşme ve derleyici onu kontrol ediyor. withRequestLog'u withRequestStatus'u olmayan bir store'a eklemeye çalışınca (çıktı kısaltıldı):
✘ [ERROR] TS2769: No overload matches this call.
Overload 1 of 45, '(f1: SignalStoreFeature<EmptyFeatureResult, { state: { isbns: string[]; }; props: {}; methods: {}; }>, f2: SignalStoreFeature<{ state: { isbns: string[]; }; props: {}; methods: {}; }, EmptyFeatureResult>): Type<...>', gave the following error.
...
Type '{ isbns: Signal<string[]>; }' is missing the following properties from type '{ requestStatus: Signal<RequestStatus>; requestError: Signal<string | null>; }': requestStatus, requestError
...
src/app/catalog/wishlist-store.ts:5:29:
5 │ export const WishlistStore = signalStore(withState({ isbns: [] as s...
╵ ~~~~~~~~~~~
Mesaj uzun ama sonu net: store'un state'inde requestStatus ve requestError yok. Sıra da önemli: feature'lar sırayla uygulandığı için withRequestLog, withRequestStatus'tan önce yazılırsa aynı hatayı veriyor.
Log için effect yerine watchState kullandık. getState(store)'u okuyan bir effect, aynı tick'teki birden çok değişiklikte yalnız son state ile bir kez çalışıyor; NgRx dokümantasyonu buna glitch-free davranış diyor ve sepetin localStorage'a kaydı için bu tam istediğimiz şey. watchState ise senkron: her patchState çağrısında watcher'ı çalıştırıyor. Bir geçiş log'u, undo/redo ya da "her değişikliği gör" gerektiren her şey için doğru araç bu. watchState da injection context istiyor ve injector destroy edildiğinde kendiliğinden kapanıyor; onInit bu koşulu sağlıyor. NgRx 22.0'da bir düzeltme daha var: watcher'lar artık untracked içinde, yani reactive context dışında çalışıyor; patchState bir effect'in içinden çağrılsa bile watcher'da okunan signal'lar o effect'e bağımlılık olarak eklenmiyor.
withFeature ve withLinkedState: Klavyeyle Seçim
Öneri listesinde ok tuşlarıyla gezinmek, Bookstore'da başka listelerde de (kategori menüsü, kurumsal portaldaki birim seçimi) işe yarayacak bir davranış. Bu yüzden onu listenin nereden geldiğini bilmeyen bir feature olarak yazdık:
import { computed, type Signal } from '@angular/core';
import { patchState, signalStoreFeature, withComputed, withLinkedState, withMethods } from '@ngrx/signals';
// Her liste için ok tuşlarıyla seçim; liste withFeature üzerinden store'dan gelir
export function withKeyboardSelection<T>(items: Signal<T[]>) {
return signalStoreFeature(
// Linked state: liste her değiştiğinde ilk elemana döner, ama yazılabilir kalır
withLinkedState(() => ({ highlighted: (): number => (items().length ? 0 : -1) })),
withComputed(({ highlighted }) => ({
highlightedItem: computed(() => items()[highlighted()] ?? null),
})),
withMethods((store) => ({
move(step: 1 | -1): void {
const count = items().length;
if (count) patchState(store, { highlighted: (store.highlighted() + step + count) % count });
},
})),
);
}
Feature, listeyi bir Signal<T[]> parametresi olarak alıyor; arama store'u onu withFeature(({ results }) => withKeyboardSelection(results)) ile bağlıyor. withFeature'ın callback'i o ana kadar kurulmuş store'u alıyor ve bir feature döndürüyor. Fark şu: type<...>() ile girdi tanımlayan bir feature, store'da belirli isimlerde alanlar olmasını şart koşuyor (requestStatus adı sabit); withFeature ise store'un iç yapısına bağlanmadan değeri parametre olarak geçiriyor. Aynı feature yarın withFeature(({ categories }) => withKeyboardSelection(categories)) ile başka bir listede kullanılabilir.
highlighted bir linked state. withLinkedState'e verilen computation fonksiyonu NgRx tarafından bir linkedSignal'a sarılıyor: liste her değiştiğinde değer yeniden hesaplanıyor (ilk öneri seçili), ama arada patchState ile yazılabiliyor (ok tuşu). İkinci yazıdaki linkedSignal'ın store içindeki karşılığı ve normal bir state alanı gibi davranıyor: getState'te görünüyor, patchState ile güncelleniyor. Arama çıktısında bu iki davranış art arda görünüyor: "em" için Emma seçiliydi, ArrowDown Middlemarch'a geçti, yeni sorgu "mi" gelince seçim tekrar ilk öneriye döndü. Bir detay: () => (items().length ? 0 : -1) yazınca TypeScript tipi 0 | -1 olarak çıkarıyor ve move'daki patchState derlenmiyor; dönüş tipini number olarak yazmak bu yüzden.
Üretimde Dikkat
Önceki yazının "Üretimde Dikkat" bölümünde, kalıcı sepetin biçimi değişince kullanıcının sepetinin sessizce kaybolduğunu görmüştük. Entity'lere geçmek biçimi bir kez daha değiştirdi: getState(store) artık _lineIds, _lineEntityMap, requestStatus ve requestError içeriyor. Bunları localStorage'a yazmak iki hata: iç yapıyı bir sözleşmeye çeviriyor ve yarım kalmış bir isteğin 'pending' durumu sayfa yenilendiğinde geri geliyor. Bu yüzden kod artık state'in kendisini değil, sürümlü ve küçük bir kopyasını kaydediyor ({ version: 2, items, coupon }) ve readSavedCart uygulamanın bugüne kadar yazdığı her biçimi okuyabiliyor. Önceki yazılarda kaydedilmiş iki sepetle açılış:
v0 array (01-04) : Cart (2) | {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2}],"coupon":null}
v1 object (05) : Cart (2) | {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2}],"coupon":{"code":"BOOKWEEK","percent":10}}
İki eski biçim de okundu ve ilk kayıtta v2'ye çevrildi; 05'teki kupon da korundu. Bu migration kodu birkaç release boyunca kalmalı, ta ki eski biçimde kayıtlı tarayıcıların pratikte kalmadığından emin olana kadar.
rxMethod ve signalMethod subscription'larının ömrü, çağrıldıkları injector'a bağlı. Root bir store'un reactive method'unu bir component'ten signal ile çağırmak, çağrı component'in injection context'inde değilse component destroy edildikten sonra da çalışmaya devam eden bir izleme bırakıyor. Kuralı basit tut: reactive method'u ya constructor ya da field initializer'da çağır, ya da { injector } ver. Arama store'unu component seviyesinde sağlamamızın bir nedeni de bu: store ve tüm subscription'ları kutuyla birlikte kapanıyor.
Bundle boyutuna da bak: rxMethod ve @ngrx/operators, RxJS operator'lerini bundle'a ekliyor. Uygulama zaten HttpClient üzerinden RxJS kullandığı için burada fark küçük; ama yalnız senkron işler için rxMethod kullanan bir store, signalMethod ile aynı işi daha az kodla yapar.
Sık Düşülen Tuzaklar
rxMethod içinde hatayı yakalamamak
Arama zincirinin tapResponse'suz, "nasılsa çalışıyor" hâli:
map((query) => query.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) return of({ items: [] as Book[] });
patchState(store, setPending());
return store._http.get<BookPage>(`${apiUrl}/books`, { params: { q: query } });
}),
// yanlış: hata yakalama yok; başarısız bir istek bütün stream'i bitirir
tap((page) => patchState(store, { results: page.items }, setFulfilled())),
Sahte API'yi ilk arama isteğine 503 dönecek şekilde başlatıp (FAIL="GET /api/books:1") üç sorgu yazınca:
0.1 s request: GET /categories
0.4 s console: [search] idle -> pending
0.4 s request: GET /books?q=em
0.7 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
0.7 s console: ERROR HttpErrorResponse
typed "em" - | Searching... | -
typed "emm" - | Searching... | -
typed "dune" - | Searching... | -
İlk istek hata aldı ve hata switchMap'ten dış stream'e çıktı. rxMethod'un subscription'ında hata callback'i olmadığı için hata yakalanmamış hata olarak console'a düştü (ERROR HttpErrorResponse) ve stream kapandı. Sonraki iki sorgu için hiç istek gitmedi; "Searching..." sonsuza kadar ekranda kaldı. Asıl koddaki tapResponse ile aynı senaryo:
0.1 s request: GET /categories
0.4 s console: [search] idle -> pending
0.4 s request: GET /books?q=em
0.7 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
0.7 s console: [search] pending -> error (HTTP 503)
typed "em" - | - | Search failed (HTTP 503)
1.3 s console: [search] error -> pending
1.3 s request: GET /books?q=emm
1.6 s console: [search] pending -> fulfilled
typed "emm" Emma | - | -
2.2 s console: [search] fulfilled -> pending
2.2 s request: GET /books?q=dune
2.6 s console: [search] pending -> fulfilled
typed "dune" Dune | - | -
Hata iç stream'de yakalandı, state'e yazıldı ve bir sonraki sorgu normal çalıştı. Kural RxJS serisinin "RxJS'te Error Handling: catchError, retry ile Backoff, finalize ve Stream'i Öldürmeden Hata Yakalamak" yazısındakiyle aynı: hata, dış stream'e değil, her isteğin kendi stream'ine ait.
Feature'lar arası gizli bağımlılık
withRequestLog girdisini type<RequestStatusFeature>() ile açıkça bildiriyor ve derleyici eksikliği yakalıyor. Aynı feature'ı girdi tanımlamadan, içeride (store as any).requestStatus() gibi bir erişimle yazmak derlenir, ama yanlış store'da çalışma zamanında patlar. NgRx dokümantasyonu birbirinden bağımsız feature'ları öneriyor; bağımlılık gerçekten gerekiyorsa girdi tipiyle görünür yap. Girdisi olan ama generic parametresi olmayan birden fazla feature aynı store'da kullanıldığında bilinen bir TypeScript sorunu çıkabiliyor; dokümantasyonun çözümü, feature'a kullanılmayan bir generic eklemek (withX<_>()).
addEntity ile "bir tane daha" demek
"Önce Tahmin Et"in cevabı: addEntity ve prependEntity var olan kimliği sessizce atlıyor. Var olanı değiştirmek için updateEntity, tamamen değiştirmek için setEntity, alanları birleştirmek için upsertEntity.
getState'i doğrudan kaydetmek
Kalıcılık için getState(store)'u olduğu gibi yazmak ilk bakışta en kısa yol. Ama bir feature eklendiğinde (bu yazıdaki withRequestStatus gibi) kaydedilen biçim de sessizce değişiyor. Kaydedilecek biçimi ayrı bir tip olarak tanımla ve sürümle.
Küçük Bir Deneme
★☆☆ RecentlyViewedStore'daki sınırı (3) store'un dışından ayarlanabilir yap: withProps ile bir _limit ekle ve track'te onu kullan. Aynı kitabı art arda iki kez açmanın listede tek kayıt bıraktığını Playwright ile doğrula.
★★☆ Kategori menüsüne withKeyboardSelection'ı ekle: küçük bir component seviyesinde store yaz, withFeature ile Categories service'inin listesini bağla. Kategoriler yeniden yüklendiğinde seçimin ilk elemana döndüğünü gözle.
★★★ Sepete "kaydet" ekle: her değişiklikte sepeti sunucuya gönderen bir rxMethod yaz ve switchMap, concatMap ve exhaustMap ile üç kez dene. Sahte API'ye küçük bir POST /api/cart ekleyip hızlı art arda yapılan değişikliklerde sunucuya hangi sırayla ne gittiğini log'la; hangisi sepet için doğru?
Bir sonraki yazıda store'lar arasındaki ilişkiye geçiyoruz: NgRx Signal Store Events plugin'i ile Bookstore'un checkout'unda "sipariş verildi" event'i sepeti boşaltacak, siparişler store'una kayıt ekleyecek ve bir bildirim gösterecek; aynı akışı method'larla yazılmış hâliyle karşılaştıracağız.