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

NgRx Signal Store Events Plugin: event, eventGroup, withReducer ve withEventHandlers ile Event-Driven State

Mert Özen 14 Kas 2026 14 dk 10 görüntülenme
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

07-event-flow-tr.png

Ö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:

SoruMethod'larEvents plugin
Kim kimi biliyor?Checkout dört bağımlılığı inject ediyorCheckout yalnız event'leri biliyor
Yeni bir tepki eklemekplaceOrder değişiyorYeni store'a bir on(...) ekleniyor
Akışı okumakTek method'da, yukarıdan aşağıBirden çok dosyaya dağılmış
Store'ların public API'siadd, show, fail gibi akışa özel method'larYalnız okunan state
Dosya ve kod miktarıDaha azEvent 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.