NgRx Signal Store Events Plugin: event, eventGroup, withReducer ve withEventHandlers ile Event-Driven State
Events plugin'i, birden çok store'un aynı olaya tepki verdiği akışlarda "ne oldu" ile "state nasıl değişir"i ayırır: component event dispatch eder, her store withReducer ile kendi state'ini değiştirir, withEventHandlers side effect'leri yürütür. Bookstore checkout'unda tek bir orderPlaced event'i sepeti boşaltıyor, siparişi kaydediyor ve bildirim gösteriyor; aynı akışın method versiyonuyla karşılaştırıyor ve henüz oluşmamış bir store'un event'i kaçırdığını gerçek çıktıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, NgRx 22.0.1 (@ngrx/signals ve Events plugin'i @ngrx/signals/events, @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'e bir bildirim store'u ekliyoruz. Sipariş verildiğinde bir mesaj gösteriyor ve bunu bir event'e tepki olarak yapıyor:
import { signalStore, withState } from '@ngrx/signals';
import { on, withReducer } from '@ngrx/signals/events';
import { ordersApiEvents } from '../checkout/checkout-events';
// Sepet ve siparişler store'uyla aynı event'lere tepki verir, ikisini de bilmeden
export const NotificationsStore = signalStore(
{ providedIn: 'root' },
withState({ message: null as string | null }),
withReducer(
on(ordersApiEvents.orderPlaced, ({ payload }) => ({ message: `Order #${payload.id} placed` })),
on(ordersApiEvents.orderFailed, ({ payload }) => ({ message: `Order failed (${payload})` })),
),
);
Bir ekip arkadaşı mesajı yalnız siparişler sayfasında göstermeye karar veriyor ve store'u ilk kez orada inject ediyor:
export class OrdersPage {
protected readonly orders = inject(OrdersStore);
// yanlış: ilk inject() burada, event dispatch edildikten sonra yapılıyor
protected readonly notifications = inject(NotificationsStore);
Kullanıcı checkout'ta "Place order"a basıyor, sipariş başarılı oluyor, orderPlaced event'i dispatch ediliyor ve uygulama siparişler sayfasına geçiyor. Sayfada "Order #1043 placed" mesajı görünür mü? Tahminini yaz; cevap "Sık Düşülen Tuzaklar"da.
Önceki iki yazıda her store kendi method'larıyla değişti. Bu yazıda birden fazla store'un aynı olaya tepki verdiği bir akışı NgRx Signal Store'un Events plugin'iyle kuruyoruz: checkout'ta verilen sipariş sepeti boşaltacak, siparişler store'una bir kayıt ekleyecek ve bir bildirim gösterecek. Aynı akışı method'larla da yazıp iki yaklaşımı karşılaştıracağız.
Neden Event? Method'larla Yazılmış Checkout
Event'siz, yalnız method'larla yazılmış bir sipariş akışı şöyle görünüyor; checkout store'u tepki vermesi gereken her store'u inject edip sırayla çağırıyor:
// Method versiyonu: checkout store'u tepki vermesi gereken her store'u kendisi çağırır
async placeOrder(): Promise<void> {
const request: OrderRequest = { items: store._cart.items(), shipping: store.shipping(), total: store.payable() };
store._orders.markPending();
try {
const { id } = await firstValueFrom(store._http.post<{ id: number }>(`${apiUrl}/orders`, request));
store._cart.clear();
store._orders.add({ id, ...request });
store._notifications.show(`Order #${id} placed`);
await store._router.navigate(['/orders']);
} catch (error) {
const message = error instanceof HttpErrorResponse ? `HTTP ${error.status}` : String(error);
store._orders.fail(message);
store._notifications.show(`Order failed (${message})`);
}
},
Bu kod çalışıyor (birazdan çıktısını göreceğiz) ve küçük bir uygulama için yeterli. Sorun, bağımlılığın yönü: checkout, sepetin nasıl boşaltıldığını, siparişlerin nerede tutulduğunu ve bildirimin nasıl gösterildiğini biliyor. Yarın sipariş sonrası bir analytics kaydı ya da stok bilgisini tazeleyen bir adım eklendiğinde yine bu method değişecek. Store'lar da dışarıya markPending, add, fail, show gibi, yalnız bu akış için var olan method'lar açmak zorunda kalıyor.
Event yaklaşımı bunu tersine çeviriyor: checkout yalnız "ne oldu"yu söylüyor (sipariş gönderildi), her store da bu olaya kendi state'inin nasıl değişeceğine kendisi karar veriyor. UI Mimari Pattern'leri serisinin "Flux ve Unidirectional Data Flow: Two-Way Binding Neden Ölçeklenmedi?" yazısındaki Flux fikrinin Signal Store'daki karşılığı bu. Events plugin'i NgRx 19.2'de deneysel olarak geldi ve NgRx 21 duyurusuyla stable oldu; aynı sürümde withEffects'in adı withEventHandlers olarak değişti, eski bir örnekte withEffects görürsen sebebi bu.
event ve eventGroup
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import type { OrderRequest, PlacedOrder } from '../models';
// Ne yapılacağı değil, ne olduğu: checkout sayfası kimin tepki vereceğini bilmez
export const checkoutEvents = eventGroup({
source: 'Checkout Page',
events: {
orderSubmitted: type<OrderRequest>(),
},
});
export const ordersApiEvents = eventGroup({
source: 'Orders API',
events: {
orderPlaced: type<PlacedOrder>(),
orderFailed: type<string>(),
},
});
eventGroup, aynı kaynaktan gelen event creator'ları bir arada tanımlıyor. Her creator çağrıldığında düz bir object döndürüyor: checkoutEvents.orderSubmitted(order) → { type: '[Checkout Page] orderSubmitted', payload: order }. Payload tipi type<...>() ile veriliyor, payload'suz bir event için type<void>(). Tek başına bir event gerekiyorsa event('[Checkout Page] Opened') da var; NgRx dokümantasyonu event tipleri için "[Kaynak] Event adı" biçimini öneriyor ve eventGroup bu biçimi kendisi üretiyor.
İsimler bir sözleşme: event'ler geçmiş zamanda ve olanı anlatıyor (orderSubmitted, orderPlaced, orderFailed). clearCart gibi bir komut adı, event'i dispatch edenin kimin ne yapacağını bildiği anlamına gelir; bu, method versiyonundaki bağımlılığı geri getirir.
withReducer: Event Gelince State Nasıl Değişir?
Siparişler store'u hem state geçişlerini hem HTTP isteğini taşıyor:
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore } from '@ngrx/signals';
import { prependEntity, withEntities } from '@ngrx/signals/entities';
import { Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events';
import { exhaustMap } from 'rxjs';
import { apiUrl } from '../api';
import { checkoutEvents, ordersApiEvents } from '../checkout/checkout-events';
import type { PlacedOrder } from '../models';
import { withRequestLog } from '../shared/with-request-log';
import { setError, setFulfilled, setPending, withRequestStatus } from '../shared/with-request-status';
// Bu tarayıcı oturumunda verilen siparişler; siparişlerin tam geçmişi server state'tir
export const OrdersStore = signalStore(
{ providedIn: 'root' },
withEntities<PlacedOrder>(),
withRequestStatus(),
withRequestLog('orders'),
// State geçişleri: event girer, kısmi state çıkar
withReducer(
on(checkoutEvents.orderSubmitted, () => setPending()),
on(ordersApiEvents.orderPlaced, ({ payload }) => [prependEntity(payload), setFulfilled()]),
on(ordersApiEvents.orderFailed, ({ payload }) => setError(payload)),
),
// Side effect'ler: HTTP çağrısı; handler'ın döndürdüğü event dispatch edilir
withEventHandlers((store, events = inject(Events), http = inject(HttpClient)) => ({
placeOrder$: events.on(checkoutEvents.orderSubmitted).pipe(
// sipariş gönderilirken gelen ikinci tıklama yok sayılır
exhaustMap(({ payload }) =>
http.post<{ id: number }>(`${apiUrl}/orders`, payload).pipe(
mapResponse({
next: ({ id }) => ordersApiEvents.orderPlaced({ id, ...payload }),
error: (error: HttpErrorResponse) => ordersApiEvents.orderFailed(`HTTP ${error.status}`),
}),
),
),
),
})),
);
withReducer, on(...) ile event'leri case reducer'lara bağlıyor. Handler, event'i (ve ikinci argüman olarak mevcut state'i) alıyor ve bir kısmi state, bir updater ya da bunların bir array'ini döndürüyor. Önceki yazının setPending(), setFulfilled() ve setError() fonksiyonları ile entity updater'ı prependEntity burada değişmeden kullanılıyor; custom feature'larda updater'ları düz fonksiyon yazmanın bir faydası daha.
Sepet, siparişin kimden geldiğini bilmeden kendini boşaltıyor:
// Sipariş verilince sepet kendini boşaltır; siparişi kimin verdiğini bilmez
withReducer(on(ordersApiEvents.orderPlaced, () => [removeAllEntities(lineConfig), { coupon: null }])),
"Önce Tahmin Et"teki bildirim store'u da aynı event'e kendi reducer'ıyla tepki veriyor. Üç store da aynı orderPlaced'i dinliyor ve hiçbiri diğerini import etmiyor.
withEventHandlers ve mapResponse: Side Effect'ler
HTTP isteği bir reducer'ın işi değil; withEventHandlers'ta duruyor. Fonksiyon store'u alıyor ve Observable'lardan oluşan bir dictionary (ya da array) döndürüyor. Events service'inin on(...) method'u, verilen event'lerin bir Observable'ını veriyor. Handler bir event döndürürse plugin onu otomatik olarak dispatch ediyor: mapResponse, başarılı yanıtı orderPlaced'e, hatayı orderFailed'e çeviriyor ve iki durumda da stream ayakta kalıyor. Önceki yazıda rxMethod için kullandığımız tapResponse'un event üreten kardeşi bu.
exhaustMap, sipariş gönderilirken gelen ikinci orderSubmitted'i yok sayıyor; buton zaten isPending() ile devre dışı kalıyor ama çift tıklama ile UI'ın güncellenmesi arasındaki küçük aralıkta store kendi kuralını koruyor. Arama için switchMap doğruydu (eski sorgu artık önemsiz); sipariş için iptal etmek de, sıraya koymak da yanlış.
Plugin'in kaynak kodunda bir detay var: handler'ın döndürdüğü değer bir event ise dispatch ediliyor, ama Events.on(...)'dan gelen event'ler işaretleniyor ve tekrar dispatch edilmiyor. Bu yüzden events.on(x).pipe(tap(...)) gibi yalnız log yazan bir handler, aynı event'i geri döndürdüğü hâlde sonsuz bir döngü oluşturmuyor.
injectDispatch ve Component'ten Dinlemek
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { Events, injectDispatch } from '@ngrx/signals/events';
import { CartStore } from '../cart/cart-store';
import { OrdersStore } from '../orders/orders-store';
import { checkoutEvents, ordersApiEvents } from './checkout-events';
import { CheckoutStore } from './checkout-store';
@Component({
selector: 'app-checkout-review',
template: `
<h1>Checkout: review</h1>
<p class="review">
Books {{ cart.payable() }} TRY + {{ checkout.shipping() }} shipping
{{ checkout.shippingPrice() }} TRY = {{ checkout.payable() }} TRY
</p>
<button class="place-order" [disabled]="orders.isPending()" (click)="placeOrder()">Place order</button>
@if (orders.requestError(); as error) {
<p class="order-error">Could not place the order ({{ error }})</p>
}
`,
})
export class CheckoutReview {
protected readonly cart = inject(CartStore);
protected readonly checkout = inject(CheckoutStore);
protected readonly orders = inject(OrdersStore);
readonly #dispatch = injectDispatch(checkoutEvents);
constructor() {
const router = inject(Router);
// Bir component de event dinleyebilir; subscription component'le birlikte biter
inject(Events)
.on(ordersApiEvents.orderPlaced)
.pipe(takeUntilDestroyed())
.subscribe(() => void router.navigate(['/orders']));
}
protected placeOrder(): void {
this.#dispatch.orderSubmitted({
items: this.cart.items(),
shipping: this.checkout.shipping(),
total: this.checkout.payable(),
});
}
}
injectDispatch(checkoutEvents), gruptaki her event için bir method içeren bir object döndürüyor; this.#dispatch.orderSubmitted(...) event'i oluşturup dispatch ediyor. Component state'i yine store'dan okuyor (orders.isPending(), orders.requestError()); Events plugin state'in okunma biçimini değiştirmiyor, yalnız değiştirilme biçimini. Siparişler sayfasına geçiş bir UI işi; component orderPlaced'i kendisi dinliyor ve subscription takeUntilDestroyed() ile component'le birlikte kapanıyor. Akışı gözlemek için header component'ine de bütün event'leri console'a yazan küçük bir geliştirme yardımcısı ekledik:
constructor() {
// Geliştirme yardımcısı: dispatch edilen her event console'da
inject(Events)
.on()
.pipe(takeUntilDestroyed())
.subscribe((event) => console.log(`[event] ${event.type}`));
}
Akış: Bir Event, Birden Çok Store

Önce Dune×2 ve Emma ile başarılı bir sipariş, sonra stokta 3 adet olan Middlemarch'tan 4 adetle reddedilen bir sipariş:
1.8 s console: [checkout] store #1 created
review: Books 1220 TRY + express shipping 90 TRY = 1310 TRY
2.4 s console: [orders] idle -> pending
2.4 s console: [event] [Checkout Page] orderSubmitted
2.7 s console: [orders] pending -> fulfilled
2.7 s console: [event] [Orders API] orderPlaced
2.7 s console: [checkout] store #1 destroyed
url /orders | Order #1043 placed | Cart (0) | -
orders page: #1043: Dune x 2 Emma x 1 - 1310 TRY (express)
7.1 s console: [checkout] store #2 created
review: Books 1520 TRY + standard shipping 0 TRY = 1520 TRY
7.4 s console: [orders] fulfilled -> pending
7.4 s console: [event] [Checkout Page] orderSubmitted
7.7 s console: Failed to load resource: the server responded with a status of 409 (Conflict)
7.7 s console: [orders] pending -> error (HTTP 409)
7.7 s console: [event] [Orders API] orderFailed
url /checkout/review | Order failed (HTTP 409) | Cart (4) | Could not place the order (HTTP 409)
Başarılı siparişte tek bir orderPlaced üç store'u birden değiştirdi: sepet boşaldı (Cart (0)), siparişler listesine #1043 eklendi ve bildirim göründü; component de siparişler sayfasına geçti. İkinci siparişte API 409 döndü; orderFailed yalnız siparişler store'unu ve bildirimi değiştirdi, sepet 4 Middlemarch ile olduğu gibi kaldı. Bu da event tasarımının bir sonucu: sepetin orderFailed için bir reducer'ı yok, yani başarısız siparişin sepete dokunmaması ayrıca kod yazmayı gerektirmedi.
Log'daki sıra da bir şey söylüyor: [orders] idle -> pending, [event] [Checkout Page] orderSubmitted'den önce yazıldı. NgRx dokümantasyonuna göre dispatch edilen event önce ReducerEvents'e, yani reducer'lara gidiyor, sonra Events'e; plugin'in kaynağında ikinci adım queueScheduler ile sıraya alınıyor. Bu yüzden handler'lar ve dinleyen component'ler state'i her zaman reducer'lar çalıştıktan sonra görüyor.
Method Versiyonuyla Karşılaştırma
Yazının başındaki method tabanlı placeOrder ile aynı Playwright senaryosu:
1.8 s console: [checkout] store #1 created
review: Books 1220 TRY + express shipping 90 TRY = 1310 TRY
2.4 s console: [orders] idle -> pending
2.7 s console: [orders] pending -> fulfilled
2.7 s console: [checkout] store #1 destroyed
url /orders | Order #1043 placed | Cart (0) | -
orders page: #1043: Dune x 2 Emma x 1 - 1310 TRY (express)
7.0 s console: [checkout] store #2 created
review: Books 1520 TRY + standard shipping 0 TRY = 1520 TRY
7.3 s console: [orders] fulfilled -> pending
7.6 s console: Failed to load resource: the server responded with a status of 409 (Conflict)
7.6 s console: [orders] pending -> error (HTTP 409)
url /checkout/review | Order failed (HTTP 409) | Cart (4) | Could not place the order (HTTP 409)
Kullanıcının gördüğü her şey aynı; tek fark, [event] satırlarının olmaması. Yani seçim davranışla değil, kodun nasıl büyüyeceğiyle ilgili:
| Soru | Method'lar | Events plugin |
|---|---|---|
| Kim kimi biliyor? | Checkout dört bağımlılığı inject ediyor | Checkout yalnız event'leri biliyor |
| Yeni bir tepki eklemek | placeOrder değişiyor | Yeni store'a bir on(...) ekleniyor |
| Akışı okumak | Tek method'da, yukarıdan aşağı | Birden çok dosyaya dağılmış |
| Store'ların public API'si | add, show, fail gibi akışa özel method'lar | Yalnız okunan state |
| Dosya ve kod miktarı | Daha az | Event dosyası, reducer'lar, handler'lar |
Tek bir store'un içindeki basit CRUD (oluşturma, okuma, güncelleme, silme) işlemleri için event katmanı yalnız tören ekliyor: CartStore.add() yerine bir bookAddedToCart event'i, bir reducer ve bir dispatch yazmak, okunabilirliği azaltıp bir şey kazandırmıyor. NgRx dokümantasyonu da varsayılan Signal Store yaklaşımının çoğu durum için yeterli olduğunu, Events plugin'inin store'lar arası koordinasyon gibi daha ileri senaryolarda öne çıktığını söylüyor. Bizim kuralımız: bir olaya birden fazla store tepki veriyorsa ya da tepki verenler zamanla artacaksa event; yoksa method.
Üretimde Dikkat
Event'leri bir API gibi ele al: tipi ([Orders API] orderPlaced) ve payload'u, onu dinleyen her store için bir sözleşme. Payload'a yalnız serileştirilebilir, düz veri koy; HttpErrorResponse gibi bir object yerine orderFailed'e bir string verdik. Bu, ileride event'leri log'lamak ya da bir devtools'a göndermek istediğinde işini kolaylaştırır. Resmî NgRx paketlerinde Signal Store event'leri için bir Redux DevTools entegrasyonu yok; header'daki gibi bir log ya da topluluk paketi @angular-architects/ngrx-toolkit kullanılabilir (araç seçimi yazısında).
Varsayılan olarak Dispatcher ve Events uygulama genelinde tek; her dispatch edilen event her yerde duyuluyor. Bir feature'ın event'lerini kendi içinde tutmak için NgRx provideDispatcher() ile local bir scope sunuyor; dispatch ederken { scope: 'parent' } ya da { scope: 'global' } ile üst scope'a iletmek mümkün. Aynı sayfada birden fazla instance'ı olan bir widget ya da kendi event'leri dışarı sızmaması gereken bir micro frontend için bu önemli; Bookstore'un checkout'u için global scope doğru.
Event akışı dağınık olduğu için test daha önemli hâle geliyor: "orderPlaced gelince sepet boşalır" gibi her reducer'ı ayrı ayrı test etmek kolay, akışın tamamını ise ancak bir entegrasyon testi gösteriyor. Store testleri dokuzuncu yazının konusu.
Sık Düşülen Tuzaklar
Event'i duyması gereken store henüz yok
"Önce Tahmin Et"in cevabı, aynı senaryonun ilk kısmı:
1.8 s console: [checkout] store #1 created
review: Books 1220 TRY + express shipping 90 TRY = 1310 TRY
2.3 s console: [orders] idle -> pending
2.3 s console: [event] [Checkout Page] orderSubmitted
2.6 s console: [orders] pending -> fulfilled
2.6 s console: [event] [Orders API] orderPlaced
2.6 s console: [checkout] store #1 destroyed
url /orders | - | Cart (0) | -
Sipariş verildi, sepet boşaldı, ama siparişler sayfasında mesaj yok (-). NotificationsStore root'ta sağlanıyor, ama bir Signal Store ilk kez inject edildiğinde oluşturuluyor ve reducer'ı da o anda bağlanıyor. orderPlaced dispatch edildiğinde store yoktu; siparişler sayfası açılınca oluşturuldu ve başlangıç state'iyle (message: null) başladı. Event'ler bir yerde saklanmıyor ve sonradan gelen bir store'a tekrar oynatılmıyor. Çözüm, event'e tepki vermesi gereken store'un event'ten önce var olduğundan emin olmak: bizim kodumuzda header component'i NotificationsStore'u inject ediyor, siparişler store'unu da checkout sayfası (onu isPending için zaten kullanıyor).
Bir handler'da hatayı yakalamamak
Önceki yazıda tapResponse'suz bir rxMethod'un ilk hatada durduğunu gördük. withEventHandlers'ta durum biraz daha geniş: @ngrx/signals 22.0.1'in kaynağında bir store'un bütün handler'ları merge ile tek bir subscription'a bağlanıyor, yani hatayı yakalamayan tek bir handler, aynı store'daki diğer handler'ları da durdurur. Bunu ayrıca çalıştırmadık; kural aynı: her HTTP çağrısı kendi iç stream'inde mapResponse ya da tapResponse ile.
Event'i komut gibi adlandırmak
clearCart, showNotification, addOrder gibi event'ler dispatch edenin sonucu bildiğini gösterir ve her yeni tepki için yeni bir event gerektirir. Olayı adlandır (orderPlaced), tepkileri store'lar versin.
Reducer'da side effect
on(...) handler'ı yalnız yeni state'i döndürmeli. localStorage'a yazmak, navigate etmek ya da HTTP çağrısı yapmak withEventHandlers'ın (ya da bir component'in) işi; reducer'a girdiğinde aynı event'in iki kez işlenmesi ya da test edilmesi zorlaşıyor.
Küçük Bir Deneme
★☆☆ Bildirimi 4 saniye sonra kendiliğinden kapat: NotificationsStore'a orderPlaced ve orderFailed'i dinleyen bir withEventHandlers ekle, switchMap ile timer(4000) bekle ve bir notificationDismissed event'i döndür.
★★☆ Sipariş verildiğinde katalogdaki stok bilgisinin bayatladığını düşün: üçüncü yazıdaki katalog httpResource'unu orderPlaced geldiğinde reload() eden bir dinleyici yaz. Bunu event ile mi yoksa checkout'tan çağrılan bir method ile mi yapmak daha doğru?
★★★ Arama kutusunu (önceki yazı) provideDispatcher() ile kendi event scope'una taşı: searchEvents.queryChanged local kalsın, kullanıcı bir öneriyi seçtiğinde bookOpened event'i { scope: 'global' } ile uygulamaya gitsin ve son bakılan kitaplar listesi onu dinlesin. İki arama kutusunun aynı sayfada birbirini etkilemediğini doğrula.
Bir sonraki yazıda Redux modelinin NgRx'teki ilk ve hâlâ yaygın hâline geçiyoruz: klasik NgRx Store. Bookstore'un back office'i için bir siparişler feature'ını createActionGroup, createFeature, selector'lar ve functional effect'lerle yazıp Store DevTools ile izleyeceğiz.