NgRx Signal Store in Practice
bankingSeptember 28, 2026

NgRx Signal Store in Practice

State Management for Real-Time Banking Dashboards

We migrated a transaction-monitoring dashboard — a live feed of incoming transactions rendered against fraud-scoring flags, updating continuously during business hours — from classic NgRx to Signal Store over the course of one sprint. The boilerplate reduction is real and it's the part every migration writeup leads with. The part worth actually writing about is what happens to that store the first time a burst of fifty transactions a second hits it, because that's where Signal Store's defaults stop being obviously better and start needing the same deliberate engineering classic NgRx forced on you by its structure. 

Before: what a transaction feed cost in classic NgRx 

A transaction feed in classic NgRx means an action, a reducer using EntityAdapter to keep the list normalized, a memoized selector, and an effect subscribing to the WebSocket: 


 1 // actions 
 2 export const transactionReceived = createAction( 
 3   '[Transaction Feed] Received', props<{ transaction: Transaction }>()); 
 4  
 5 // reducer 
 6 export const adapter = createEntityAdapter(); 
 7 export const transactionReducer = createReducer( 
 8   adapter.getInitialState({ flaggedCount: 0 }), 
 9   on(transactionReceived, (state, { transaction }) => 
10     adapter.upsertOne(transaction, { 
11       ...state, 
12       flaggedCount: transaction.flagged ? state.flaggedCount + 1 : state.flaggedCount, 
13     })) 
14 ); 
15  
16 // selectors 
17 export const { selectAll } = adapter.getSelectors(); 
18 export const selectAllTransactions = createSelector(selectFeature, selectAll); 
19  
20 // effect 
21 loadTransactionFeed$ = createEffect(() => this.socket.connect().pipe( 
22   map(tx => transactionReceived({ transaction: tx })) 
23 )); 

Four files, four concepts, and a real amount of ceremony for "keep a list of transactions up to date." It works, and it's exactly the structure that made classic NgRx feel heavy on anything beyond a handful of features. 


After: the same feed in Signal Store 

 1 export const TransactionStore = signalStore( 
 2   { providedIn: 'root' }, 
 3   withEntities(), 
 4   withState({ flaggedCount: 0 }), 
 5   withMethods((store, socket = inject(TransactionSocket)) => ({ 
 6     connectFeed: rxMethod(pipe( 
 7       switchMap(() => socket.connect()), 
 8       tap(tx => patchState( 
 9         store, 
10         addEntity(tx), 
11         state => ({ flaggedCount: tx.flagged ? state.flaggedCount + 1 : state.flaggedCount }) 
12       )) 
13     )), 
14   })), 
15   withComputed(({ entities }) => ({ 
16     transactionCount: computed(() => entities().length), 
17   })), 
18 ); 

One file, withEntities replacing the hand-rolled EntityAdapter reducer entirely, rxMethod owning the WebSocket subscription's lifecycle without a separate effects class, and patchState doing in one call what the reducer plus action plus dispatch used to take three files to coordinate. This is the genuine win, and it's not superficial — fewer files means fewer places a bug can hide in the plumbing between an event arriving and state reflecting it. 


Where it gets harder: the store doesn't know your feed is fast 


Here's the part that doesn't show up in a tutorial built around a form submission or a single API call. Classic NgRx's structure — an action stream flowing through a single dispatcher — meant a burst of rapid actions often got naturally batched: Angular's Zone.js-driven change detection, triggered once per macrotask, tended to coalesce several dispatches arriving in the same tick into a single render pass, whether or not any given team intended that as a feature. It wasn't a documented guarantee to build on, but it was a real, commonly observed side effect of the architecture — a de facto rate limiter nobody had to build. 


Signals don't work that way by design, and that's usually presented as the upgrade: a patchState call notifies exactly the computed signals and template bindings that actually read the changed slice, with no whole-store selector re-evaluation. Under a moderate update rate, that's strictly better — more precise, less wasted computation. Under a genuine firehose — fifty-plus transactions a second during a load spike — calling patchState once per incoming message means the signal graph propagates and templates re-render once per message too, with nothing in the framework batching that for you the way Zone.js's macrotask coalescing incidentally used to. The fine-grained precision that makes Signal Store efficient at moderate volume is the same property that makes it do more total render work than the old selector-memoized store at high volume, if you feed it one message at a time. 


The fix is explicit, not automatic, and it belongs inside the rxMethod pipe where the stream is actually being consumed: 

 1 connectFeed: rxMethod(pipe( 
 2   switchMap(() => socket.connect().pipe( 
 3     bufferTime(100),                        // coalesce a burst into one batch 
 4     filter(batch => batch.length > 0), 
 5   )), 
 6   tap(batch => patchState( 
 7     store, 
 8     addEntities(batch), 
 9     state => ({ 
10       flaggedCount: state.flaggedCount + batch.filter(tx => tx.flagged).length, 
11     }) 
12   )), 
13 )), 

bufferTime(100) trades a hundred milliseconds of latency. This is invisible to a human watching a dashboard, for one patchState call and one render pass per batch instead of one per message. That's the actual lesson from this migration: Signal Store didn't remove the need to think about update frequency, it moved that thinking from something the architecture handled incidentally to something the engineer has to handle deliberately, in the one place — the rxMethod pipe — where it's actually visible and testable, rather than an emergent property of how Zone.js happened to schedule things. 


One more honest gap: tooling maturity 


Worth a brief, honest mention for a team weighing the migration: NgRx DevTools' time-travel debugging and action-log inspection — genuinely useful during an incident review on a transaction-monitoring dashboard, where "what sequence of events produced this displayed state" is exactly the kind of question that comes up — is built around the action-stream model classic NgRx uses. Signal Store's tooling story here is less mature; there's no equivalent action log to replay, because there's no action stream to log. For a dashboard where auditability of state transitions matters as much as the state itself, that's a real trade-off to weigh against the boilerplate savings, not a footnote. 


What the migration actually bought 

Less code, genuinely — the four-file, four-concept structure collapsed into one colocated store definition, and withEntities alone removed most of the hand-rolled normalization logic. What it didn't buy for free was performance under load; that still has to be engineered, just in a different place than before, and the place it moved to is more legible once you know to look for it. For a real-time banking dashboard specifically, that trade is worth making — but only with the batching built in deliberately, not assumed as a side effect of the new architecture the way it quietly was with the old one.