Klasik NgRx Store: createActionGroup, createFeature, Selector'lar, Functional Effect'ler ve DevTools
Klasik NgRx Store, state'i tek bir global ağaçta tutar ve yalnız action'larla değiştirir: createActionGroup action'ları, createFeature reducer'ı ve otomatik selector'ları, createSelector memoized türetilmiş veriyi, functional effect'ler side effect'leri tanımlar. Bookstore'un back office siparişlerini yazıyor; NgRx 22'nin DevTools actionCreators seçeneğini, ESLint kurallarının yakaladığı hataları ve memoize edilmeyen bir selector'ın fazladan değer ürettiğini gerçek çıktıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, NgRx 22.0.1 (@ngrx/store, @ngrx/effects, @ngrx/store-devtools, @ngrx/operators, @ngrx/eslint-plugin), ESLint 10.15, typescript-eslint 8.72, 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çı) back office'i için bir siparişler sayfası yazıyoruz; state bu kez klasik NgRx Store'da. Sayfa, filtrelenmiş sipariş listesini şöyle okuyor; selectSignal bir selector'ı signal olarak döndürüyor:
// yanlış: inline bir selector, filter() her action'da yeni bir array döndürür
protected readonly orders = this.#store.selectSignal((state) => {
const status = adminOrdersFeature.selectStatusFilter(state);
const orders = adminOrdersFeature.selectOrders(state);
return status === 'all' ? orders : orders.filter((o) => o.status === status);
});
Filtre "new" iken kullanıcı #1041'in "Ship" butonuna basıyor. Bu iki action dispatch ediyor: önce Ship Clicked (state'te yalnız gönderilmekte olan siparişlerin listesi değişiyor), sonra API'den yanıt gelince Order Shipped (siparişin durumu değişiyor). orders signal'ı bu iki action'da kaç kez yeni bir değer verir, ve createSelector ile yazılmış bir selector'da bu sayı değişir mi? Tahminini yaz; cevap "Sık Düşülen Tuzaklar"da.
Önceki yazılarda Signal Store ile çalıştık. Klasik NgRx Store, Redux modelinin NgRx'teki ilk hâli: tek bir global state ağacı, state'i değiştiren tek yol action, state geçişlerini yapan saf fonksiyonlar (reducer), state'ten veri türeten memoized fonksiyonlar (selector) ve side effect'leri yürüten effect'ler. Modelin kendisi UI Mimari Pattern'leri serisinin "Flux ve Unidirectional Data Flow: Two-Way Binding Neden Ölçeklenmedi?" yazısında; burada bugünkü API'yle yazıyoruz: createActionGroup, createFeature, selector'lar, functional effect'ler, Store DevTools ve NgRx'in ESLint kuralları. Kurulum:
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
Action'lar: createActionGroup
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import type { AdminOrder, OrderStatus } from '../models';
// Action'lar tek bir kaynaktan gelen olayları anlatır; tip '[Admin Orders Page] Opened' olur
export const adminOrdersPageActions = createActionGroup({
source: 'Admin Orders Page',
events: {
Opened: emptyProps(),
'Status Filter Changed': props<{ status: OrderStatus | 'all' }>(),
'Ship Clicked': props<{ id: number }>(),
},
});
export const adminOrdersApiActions = createActionGroup({
source: 'Admin Orders API',
events: {
'Orders Loaded': props<{ orders: AdminOrder[] }>(),
'Orders Load Failed': props<{ error: string }>(),
'Order Shipped': props<{ order: AdminOrder }>(),
'Order Ship Failed': props<{ id: number; error: string }>(),
},
});
createActionGroup, aynı kaynaktan gelen action creator'ları birlikte tanımlıyor; action tipi [Admin Orders Page] Status Filter Changed gibi, kaynak ve event adından oluşuyor. Event adları okunabilir cümleler olarak yazılıyor ve creator'lar camelCase'e çevriliyor: adminOrdersPageActions.statusFilterChanged({ status }). Payload props<...>() ile, payload'suz bir action emptyProps() ile tanımlanıyor. Önceki yazıdaki Signal Store eventGroup'unun neredeyse aynısı; fark, orada tipin [Checkout Page] orderSubmitted biçiminde anahtarın kendisinden üretilmesi ve payload'un payload alanında, burada ise props'un action'ın kendi alanları olması.
İki grup var, çünkü iki kaynak var: kullanıcının sayfada yaptıkları ve API'nin yanıtları. Bu ayrım, action log'unu okurken "bu değişiklik kullanıcıdan mı, sunucudan mı geldi?" sorusunu cevaplıyor.
createFeature: Reducer ve Selector'lar
import { createFeature, createReducer, createSelector, on } from '@ngrx/store';
import type { AdminOrder, OrderStatus } from '../models';
import { adminOrdersApiActions, adminOrdersPageActions } from './orders.actions';
interface AdminOrdersState {
orders: AdminOrder[];
statusFilter: OrderStatus | 'all';
loading: boolean;
shippingIds: number[];
error: string | null;
}
const initialState: AdminOrdersState = { orders: [], statusFilter: 'all', loading: false, shippingIds: [], error: null };
// Feature = ad + reducer + üretilen selector'lar (selectOrders, selectStatusFilter, ...)
export const adminOrdersFeature = createFeature({
name: 'adminOrders',
// açık dönüş tipleri: derleyici yanlış yazılmış ya da bilinmeyen bir state alanını reddeder
reducer: createReducer(
initialState,
on(adminOrdersPageActions.opened, (state): AdminOrdersState => ({ ...state, loading: true, error: null })),
on(adminOrdersPageActions.statusFilterChanged, (state, { status }): AdminOrdersState => ({ ...state, statusFilter: status })),
on(adminOrdersPageActions.shipClicked, (state, { id }): AdminOrdersState => ({ ...state, shippingIds: [...state.shippingIds, id] })),
on(adminOrdersApiActions.ordersLoaded, (state, { orders }): AdminOrdersState => ({ ...state, orders, loading: false })),
on(adminOrdersApiActions.ordersLoadFailed, (state, { error }): AdminOrdersState => ({ ...state, loading: false, error })),
on(adminOrdersApiActions.orderShipped, (state, { order }): AdminOrdersState => ({
...state,
orders: state.orders.map((o) => (o.id === order.id ? order : o)),
shippingIds: state.shippingIds.filter((id) => id !== order.id),
})),
on(adminOrdersApiActions.orderShipFailed, (state, { id, error }): AdminOrdersState => ({
...state,
shippingIds: state.shippingIds.filter((x) => x !== id),
error,
})),
),
// Türetilmiş veri: memoized, yalnız bir input selector yeni bir değer döndürünce yeniden hesaplanır
extraSelectors: ({ selectOrders, selectStatusFilter }) => ({
selectVisibleOrders: createSelector(selectOrders, selectStatusFilter, (orders, status) =>
status === 'all' ? orders : orders.filter((o) => o.status === status),
),
selectNewCount: createSelector(selectOrders, (orders) => orders.filter((o) => o.status === 'new').length),
}),
});
createReducer, on(...) ile action'ları state geçişlerine bağlıyor; her geçiş, eski state'i değiştirmeden yeni bir object döndürüyor. createFeature ise feature adını, reducer'ı ve selector'ları tek bir object'te topluyor: state'in her alanı için bir selector kendiliğinden üretiliyor (selectOrders, selectStatusFilter, selectShippingIds…) ve extraSelectors ile türetilmiş selector'lar ekleniyor. createFeature NgRx 12.1'de, extraSelectors 15.2'de geldi; eskiden bunların her biri ayrı bir createFeatureSelector ve createSelector satırıydı.
selectVisibleOrders ve selectNewCount memoized: NgRx dokümantasyonuna göre createSelector son argümanlarını hatırlıyor ve input selector'lar aynı değeri döndürdüğü sürece projector fonksiyonunu tekrar çağırmadan son sonucu veriyor. "Yalnız yeni siparişler" listesi bu yüzden shippingIds değiştiğinde yeniden hesaplanmıyor; orders ya da statusFilter değişince hesaplanıyor. Memoized değer bellekte kalıyor; büyük bir sonucu tutan selector için release() ile temizlemek mümkün.
Reducer'daki her on fonksiyonunun dönüş tipinin açıkça AdminOrdersState yazıldığına dikkat et; nedeni "ESLint Kuralları" bölümünde.
Functional Effect'ler ve Route Seviyesinde Kayıt
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { exhaustMap, mergeMap } from 'rxjs';
import { apiUrl } from '../api';
import type { AdminOrder } from '../models';
import { adminOrdersApiActions, adminOrdersPageActions } from './orders.actions';
// Functional effect'ler: class yok, bağımlılıklar varsayılan parametre olarak
export const loadOrders = createEffect(
(actions$ = inject(Actions), http = inject(HttpClient)) => {
return actions$.pipe(
ofType(adminOrdersPageActions.opened),
exhaustMap(() =>
http.get<AdminOrder[]>(`${apiUrl}/orders`).pipe(
mapResponse({
next: (orders) => adminOrdersApiActions.ordersLoaded({ orders }),
error: (error: HttpErrorResponse) => adminOrdersApiActions.ordersLoadFailed({ error: `HTTP ${error.status}` }),
}),
),
),
);
},
{ functional: true },
);
export const shipOrder = createEffect(
(actions$ = inject(Actions), http = inject(HttpClient)) => {
return actions$.pipe(
ofType(adminOrdersPageActions.shipClicked),
// farklı siparişler paralel gönderilebilir
mergeMap(({ id }) =>
http.post<AdminOrder>(`${apiUrl}/orders/${id}/ship`, {}).pipe(
mapResponse({
next: (order) => adminOrdersApiActions.orderShipped({ order }),
error: (error: HttpErrorResponse) =>
adminOrdersApiActions.orderShipFailed({ id, error: `HTTP ${error.status}` }),
}),
),
),
);
},
{ functional: true },
);
NgRx 15.2'den beri effect'ler için class gerekmiyor: createEffect'e bir fonksiyon ve { functional: true } veriliyor, bağımlılıklar inject() ile varsayılan parametre olarak alınıyor. Bu, test ederken bağımlılıkları argüman olarak geçmeyi de kolaylaştırıyor (dokuzuncu yazı). Effect, ofType ile ilgilendiği action'ları süzüyor ve bir Observable action döndürüyor; dönen her action store'a dispatch ediliyor. Hata yönetimi önceki iki yazıyla aynı: mapResponse iç stream'de, böylece başarısız bir istek effect'i durdurmuyor.
Operator seçimi burada da bir iş kuralı. Liste yüklenirken gelen ikinci Opened'ı exhaustMap yok sayıyor; farklı siparişleri göndermek ise birbirinden bağımsız, bu yüzden mergeMap ile paralel çalışıyor.
Store uygulama seviyesinde bir kez sağlanıyor, feature state'i ve effect'leri ise back office route'unun providers'ında:
// Signal Store'ların yanında klasik NgRx Store; feature state'ini admin route'u kaydeder
provideStore({}, { metaReducers: isDevMode() ? [logActions] : [] }),
provideStoreDevtools({
maxAge: 25,
logOnly: !isDevMode(),
// NgRx 22: bu action'lar Redux DevTools eklentisinden dispatch edilebilir
actionCreators: adminOrdersPageActions,
}),
{
path: 'admin/orders',
component: AdminOrdersPage,
// back office feature'ı state'ini ve effect'lerini route ilk kez açıldığında kaydeder
providers: [provideState(adminOrdersFeature), provideEffects(adminOrdersEffects)],
},
Müşteri tarafı bu kodu hiç yüklemeden çalışıyor; adminOrders state'i ve effect'ler, kullanıcı ilk kez /admin/orders'a girdiğinde kayıt oluyor. provideStore'a verilen logActions bir meta-reducer: NgRx dokümantasyonu meta-reducer'ları action'lar reducer'lara ulaşmadan önce araya giren bir katman olarak tanımlıyor. Bizimki yalnız geliştirme modunda her action'ın tipini yazıyor:
import type { ActionReducer } from '@ngrx/store';
// Meta-reducer: her reducer'ı sarar; burada yalnız action tipini console'a yazar
export function logActions<S>(reducer: ActionReducer<S>): ActionReducer<S> {
return (state, action) => {
console.log(`[action] ${action.type}`);
return reducer(state, action);
};
}
Component'te selectSignal
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import type { OrderStatus } from '../models';
import { adminOrdersPageActions } from './orders.actions';
import { adminOrdersFeature } from './orders.feature';
@Component({
selector: 'app-admin-orders-page',
template: `
<h1>Back office: orders ({{ newCount() }} new)</h1>
<select class="status-filter" [value]="statusFilter()" (change)="filter($any($event.target).value)">
<option value="all">All</option>
<option value="new">New</option>
<option value="shipped">Shipped</option>
</select>
@if (error(); as error) {
<p class="admin-error">{{ error }}</p>
}
<ul class="admin-orders">
@for (order of orders(); track order.id) {
<li>
#{{ order.id }} {{ order.customer }} - {{ order.total }} TRY - {{ order.status }}
@if (order.status === 'new') {
<button class="ship" [disabled]="shippingIds().includes(order.id)" (click)="ship(order.id)">Ship</button>
}
</li>
}
</ul>
`,
})
export class AdminOrdersPage {
readonly #store = inject(Store);
// selectSignal: signal olarak okunan bir selector
protected readonly orders = this.#store.selectSignal(adminOrdersFeature.selectVisibleOrders);
protected readonly newCount = this.#store.selectSignal(adminOrdersFeature.selectNewCount);
protected readonly statusFilter = this.#store.selectSignal(adminOrdersFeature.selectStatusFilter);
protected readonly shippingIds = this.#store.selectSignal(adminOrdersFeature.selectShippingIds);
protected readonly error = this.#store.selectSignal(adminOrdersFeature.selectError);
constructor() {
this.#store.dispatch(adminOrdersPageActions.opened());
}
protected filter(status: OrderStatus | 'all'): void {
this.#store.dispatch(adminOrdersPageActions.statusFilterChanged({ status }));
}
protected ship(id: number): void {
this.#store.dispatch(adminOrdersPageActions.shipClicked({ id }));
}
}
store.selectSignal(selector) (NgRx 16'dan beri) bir selector'ı Angular signal'ı olarak döndürüyor; template'te orders() diye okunuyor ve async pipe ya da subscription gerekmiyor. Component state'i değiştirmiyor, yalnız action dispatch ediyor: sayfa açıldığında opened(), filtrede statusFilterChanged, butonda shipClicked.
Store DevTools ve actionCreators
provideStoreDevtools, store'u Redux DevTools tarayıcı eklentisine bağlıyor: her action ve sonrasındaki state eklentide listeleniyor, geçmiş bir action'a dönüp uygulamayı o state ile görmek (time travel) mümkün oluyor. maxAge: 25 geçmişte tutulan action sayısını sınırlıyor; NgRx dokümantasyonu bunun performans için kritik olduğunu ve varsayılanın sınırsız olduğunu söylüyor. NgRx 22 ile gelen actionCreators seçeneği, verilen creator'ları eklentinin dispatch panelinde listeliyor; bir action group verildiğinde creator'lar anahtar adlarıyla görünüyor.
Eklentiyi headless bir tarayıcıya kuramadığımız için, eklentinin sayfaya eklediği window.__REDUX_DEVTOOLS_EXTENSION__ API'sini (connect, send, subscribe) taklit eden ve gelenleri console'a yazan küçük bir stub kullandık; eklentinin kendi arayüzü ve time travel bu yazıda çalıştırılmadı. Sayfa açılışı, filtre, #1041'i gönderme ve son olarak "eklentiden" shipClicked({ id: 1042 }) dispatch etme:
0.1 s console: [devtools] connect maxAge=25 actionCreators: opened(props), statusFilterChanged(props), shipClicked(props)
0.1 s console: [action] @ngrx/store/init
0.1 s console: [action] @ngrx/store/update-reducers
0.1 s console: [action] @ngrx/store-devtools/recompute
0.1 s console: [action] @ngrx/store-devtools/recompute
0.1 s console: [action] @ngrx/effects/init
0.1 s console: [devtools] @ngrx/effects/init | state: 0 orders, filter=all, shipping=[]
0.1 s console: [action] [Admin Orders Page] Opened
0.1 s console: [devtools] [Admin Orders Page] Opened | state: 0 orders, filter=all, shipping=[]
0.4 s console: [action] [Admin Orders API] Orders Loaded
0.4 s console: [devtools] [Admin Orders API] Orders Loaded | state: 3 orders, filter=all, shipping=[]
opened: Back office: orders (2 new) | #1040 Ada - 490 TRY - shipped | #1041 Alan - 640 TRY - new Ship | #1042 Grace - 430 TRY - new Ship
1.2 s console: [action] [Admin Orders Page] Status Filter Changed
1.2 s console: [devtools] [Admin Orders Page] Status Filter Changed | state: 3 orders, filter=new, shipping=[]
filter=new: Back office: orders (2 new) | #1041 Alan - 640 TRY - new Ship | #1042 Grace - 430 TRY - new Ship
1.5 s console: [action] [Admin Orders Page] Ship Clicked
1.5 s console: [devtools] [Admin Orders Page] Ship Clicked | state: 3 orders, filter=new, shipping=[1041]
1.8 s console: [action] [Admin Orders API] Order Shipped
1.8 s console: [devtools] [Admin Orders API] Order Shipped | state: 3 orders, filter=new, shipping=[]
ship #1041: Back office: orders (1 new) | #1042 Grace - 430 TRY - new Ship
2.4 s console: [action] [Admin Orders Page] Ship Clicked
2.4 s console: [devtools] [Admin Orders Page] Ship Clicked | state: 3 orders, filter=new, shipping=[1042]
2.7 s console: [action] [Admin Orders API] Order Shipped
2.7 s console: [devtools] [Admin Orders API] Order Shipped | state: 3 orders, filter=new, shipping=[]
devtools dispatch: Back office: orders (0 new) |
Bağlantı kurulurken eklentiye üç creator ve parametreleri gidiyor (opened için de props yazması, NgRx'in parametre adlarını creator fonksiyonunun kaynağından çıkarmasından). Her kullanıcı action'ını önce meta-reducer'ın satırı, sonra eklentiye giden action ve state izliyor. En sondaki Ship Clicked bir butondan değil, stub'ın eklenti adına gönderdiği { selected: 2, args: ['{ id: 1042 }'] } mesajından geldi: NgRx 22 bu mesajı actionCreators'taki üçüncü creator'a (shipClicked) çevirip dispatch etti ve effect, gerçek bir tıklamadaki gibi isteği gönderdi. Başlıktaki sayaç 2 → 1 → 0.
Uygulama açılışındaki @ngrx/store/init, @ngrx/store/update-reducers ve @ngrx/effects/init NgRx'in kendi action'ları; update-reducers, route'un provideState'i feature reducer'ını eklediğinde geliyor.
ESLint Kuralları
@ngrx/eslint-plugin, NgRx'e özgü hataları yakalayan kurallar içeriyor. Flat config ile store, effects, signals ve operators kural setleri:
// Store kodu için NgRx lint kuralları (npm run lint)
import ngrx from '@ngrx/eslint-plugin';
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['src/**/*.ts'],
languageOptions: { parser: tseslint.parser },
extends: [...ngrx.configs.store, ...ngrx.configs.effects, ...ngrx.configs.signals, ...ngrx.configs.operators],
});
Kodun ilk hâli (açık dönüş tipleri ve blok gövdeler eklenmeden önce) için npm run lint:
> bookstore-web@0.0.0 lint
> eslint src
src/app/admin/orders.effects.ts
13:5 error The callback of `Effect` should be wrapped in a block statement @ngrx/prefer-effect-callback-in-block-statement
29:5 error The callback of `Effect` should be wrapped in a block statement @ngrx/prefer-effect-callback-in-block-statement
src/app/admin/orders.feature.ts
20:39 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
21:52 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
22:44 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
23:44 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
24:48 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
25:44 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
30:47 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
src/app/shared/with-request-log.ts
6:10 error Add an unused generic type to the function creating the signal store feature @ngrx/signal-store-feature-should-use-generic-type
✖ 10 problems (10 errors, 0 warnings)
3 errors and 0 warnings potentially fixable with the `--fix` option.
Üç kural, üç farklı şey söylüyor. prefer-effect-callback-in-block-statement bir stil kuralı. on-function-explicit-return-type ise gerçek bir hatayı önlüyor: (state) => ({ ...state, loding: true }) gibi yanlış yazılmış bir alan, dönüş tipi yazılmadığında TypeScript'in fazla property kontrolüne takılmıyor ve sessizce derleniyor; dönüş tipi yazıldığında:
✘ [ERROR] TS2561: Object literal may only specify known properties, but 'loding' does not exist in type 'AdminOrdersState'. Did you mean to write 'loading'? [plugin angular-compiler]
src/app/admin/orders.feature.ts:21:80:
21 │ ...): AdminOrdersState => ({ ...state, loding: true, error: null })),
╵ ~~~~~~
Dönüş tipi olmadan aynı satırla ng build hatasız tamamlandı; yani loading hiç true olmayacak ve bunu yalnız ekranda fark edecektin. Üçüncüsü signal-store-feature-should-use-generic-type, altıncı yazıdaki withRequestLog'u buldu: girdisi olan ama generic parametresi olmayan bir custom feature. Orada NgRx dokümantasyonundaki bilinen TypeScript sorununu anlatmıştık; kural aynı sorunu önlemek için feature'a kullanılmayan bir generic istiyor. Düzeltme withRequestLog<_>(name: string); üç düzeltmeden sonra npm run lint hiçbir sorun bildirmiyor.
Signal Store ile Yan Yana
Bu yazının sonunda Bookstore'da iki NgRx yaklaşımı birlikte çalışıyor: sepet, arama, son bakılanlar ve müşteri siparişleri Signal Store'da; back office siparişleri klasik Store'da. İkisi birbirini engellemiyor, çünkü farklı mekanizmalar: klasik Store'un action'ları Signal Store'un withReducer'ına gitmiyor, Signal Store event'leri de klasik reducer'lara. Gerçek bir projede geçiş genelde feature feature yapılıyor: yeni feature Signal Store ile yazılıyor, eski feature'lar dokunuldukça taşınıyor. İki feature'ın aynı olaya tepki vermesi gerekiyorsa bir köprü gerekir; örneğin bir Signal Store'da inject(Actions).pipe(ofType(...))'ı rxMethod ile dinlemek. Bu köprüleri az ve görünür tut.
Klasik Store ne zaman hâlâ mantıklı? Codebase zaten onunla yazılmışsa ve iyi çalışıyorsa, taşımanın getirisi maliyetini nadiren karşılıyor. Büyük bir ekipte her state değişikliğinin bir action olarak log'lanması, Redux DevTools'ta time travel ve katı bir "state yalnız reducer'da değişir" disiplini istendiğinde de güçlü bir seçenek. Yeni, küçük ya da orta ölçekli bir feature için ise aynı işi Signal Store daha az dosyayla yapıyor; bu karşılaştırma onuncu yazının konusu.
Üretimde Dikkat
Store DevTools üretimde de çalışabiliyor; logOnly: !isDevMode() üretimde eklentiyi yalnız log moduna alıyor (dispatch, time travel gibi özellikler kapalı). Eklentisi olan herhangi bir kullanıcı state'i görebileceği için state'te token, kişisel veri ya da iç fiyat kuralı gibi bir şey varsa actionSanitizer ve stateSanitizer ile maskele ya da DevTools'u üretim build'ine hiç ekleme. maxAge'i küçük tut: her action ile birlikte state'in bir kopyası tutuluyor.
logActions meta-reducer'ı yalnız isDevMode() iken ekleniyor; her action'da çalışan bir meta-reducer'ın ağır bir işi uygulamanın her tıklamasını yavaşlatır. Feature state'ini route seviyesinde kaydetmek, back office kodunu müşteri bundle'ının dışında tutmanın da ilk adımı; route'u ve providers'ını loadChildren ile ayrı bir routes dosyasına taşırsan reducer, effect'ler ve sayfa birlikte ayrı bir chunk'ta yüklenir (biz bunu bu yazıda yapmadık).
Klasik Store'da server state'i reducer'da tutmak bu yazıdaki gibi yaygın bir kalıp; ama ilk yazıdaki uyarı geçerli: bu bir kopya ve bayatlayabilir. Sayfa her açıldığında Opened ile listeyi yeniden çekmek basit bir çözüm; liste uzun süre açık kalıyorsa yenileme stratejisini açıkça düşün.
Sık Düşülen Tuzaklar
Selector'da her seferinde yeni bir array döndürmek
"Önce Tahmin Et"teki inline selector ile, orders signal'ının her değişikliğini log'layan geçici bir effect ekleyip aynı senaryoyu çalıştırdık:
0.1 s console: [action] [Admin Orders Page] Opened
0.1 s console: [page] orders signal changed: 0 orders
0.4 s console: [action] [Admin Orders API] Orders Loaded
0.4 s console: [page] orders signal changed: 3 orders
1.2 s console: [action] [Admin Orders Page] Status Filter Changed
1.2 s console: [page] orders signal changed: 2 orders
1.5 s console: [action] [Admin Orders Page] Ship Clicked
1.5 s console: [page] orders signal changed: 2 orders
1.8 s console: [action] [Admin Orders API] Order Shipped
1.8 s console: [page] orders signal changed: 1 orders
Ve adminOrdersFeature.selectVisibleOrders ile:
0.1 s console: [action] [Admin Orders Page] Opened
0.1 s console: [page] orders signal changed: 0 orders
0.4 s console: [action] [Admin Orders API] Orders Loaded
0.4 s console: [page] orders signal changed: 3 orders
1.2 s console: [action] [Admin Orders Page] Status Filter Changed
1.2 s console: [page] orders signal changed: 2 orders
1.5 s console: [action] [Admin Orders Page] Ship Clicked
1.8 s console: [action] [Admin Orders API] Order Shipped
1.8 s console: [page] orders signal changed: 1 orders
Inline selector'da Ship Clicked da yeni bir değer üretti: liste içerik olarak aynıydı (2 sipariş), ama filter her çağrıda yeni bir array döndürdüğü için signal bunu değişiklik saydı ve ona bağlı her şey yeniden çalıştı. Memoized selector'da Ship Clicked sessiz geçti, çünkü input'ları (orders ve statusFilter) değişmemişti. Burada fark bir effect çalışması, ama büyük bir listeyi gösteren ve birçok action alan bir sayfada her action'da yeniden hesaplanan liste ve change detection anlamına geliyor. Türetilmiş veriyi createSelector ya da extraSelectors'ta tanımla.
Her şeyi global store'a koymak
Bir dialog'un açık olup olmadığı, bir input'un o anki değeri ya da tek component'in kullandığı bir sekme seçimi için action, reducer ve selector yazmak, ilk yazıdaki "state'i gereğinden yukarı taşımak" hatasının klasik Store'daki hâli. Bu state component'te kalmalı; store, birden çok yerden okunan ve değişiklik geçmişi önemli olan state için.
Effect'te hatayı dış stream'de yakalamak
catchError ya da mapResponse'u exhaustMap'in dışına, actions$.pipe(...)'in sonuna koymak, ilk hatada effect'in action dinlemeyi bırakmasına yol açıyor; altıncı yazıdaki rxMethod tuzağıyla aynı. Hata yönetimi her zaman iç, yani HTTP stream'inde.
Action'ı komut gibi kullanmak
[Admin Orders Page] Load Orders yerine [Admin Orders Page] Opened: action, olanı anlatıyor. Aynı action'a ileride başka bir reducer ya da effect de tepki verebilir (örneğin sayfa açılış sayısını ölçen bir analytics effect'i); komut adı bunu tuhaflaştırıyor.
Küçük Bir Deneme
★☆☆ selectNewCount'u kullanarak header'a back office için "2 new orders" rozeti ekle ve bir siparişi gönderdikten sonra rozetin azaldığını gözle. Rozet müşteri sayfalarında görünmemeli; bunu nasıl sağlarsın?
★★☆ Store DevTools'a bir stateSanitizer ekle: adminOrders.orders'taki müşteri adlarını "***" ile değiştirsin. Bu yazıdaki stub ile eklentiye giden state'te adların maskelendiğini, uygulamanın kendi state'inde ise değişmediğini doğrula.
★★★ Bir köprü yaz: Signal Store'daki ordersApiEvents.orderPlaced event'i geldiğinde klasik Store'a bir [Checkout] Order Placed action'ı dispatch et ve back office reducer'ı yeni siparişi listeye eklesin. Köprü hangi tarafta yaşamalı ve bir gün iki taraftan biri kaldırıldığında ne değişir?
Bir sonraki yazıda, şimdiye kadar yazdığımız state kodunun testlerine geçiyoruz: signal tabanlı service'ler, Signal Store ve bu yazıdaki reducer, selector ve effect'ler için Vitest ile birim testleri.