Compare mental models, boilerplate, performance, and when to choose one over the other
TL;DR
- NgRx shines for large apps, team governance, auditing, devtools, predictable flows, and complex async side effects.
- Signals shine for local/component state, fine-grained performance, quick prototyping, and less boilerplate.
- Most teams benefit from a hybrid: use Signals (or ComponentStore) for feature/local state and NgRx for cross-cutting domain state.
- Compare mental models, boilerplate, performance, and when to choose one over the other
1) Mental models
NgRx (Redux-inspired, event → reducer → new state)
- Events (Actions): plain objects that describe “what happened”.
- Reducers: pure functions that compute the next immutable state.
- Selectors: memoized queries over the store.
- Effects: handle async work and emit new actions.
Benefits: strict unidirectional flow, time-travel debugging, easy to audit, consistent patterns in big teams.
Signals (fine-grained reactive variables)
- signal() holds a value, computed() derives, effect() reacts.
- Updates are targeted: only dependents re-render.
- State tends to be colocated with the feature/component using it.
Benefits: tiny learning curve, minimal ceremony, fast UI updates, great for local & view state.
2) Boilerplate & Developer Experience
NgRx
- Actions, reducers, selectors, and sometimes effects → more files, great structure.
- Excellent DevTools (time travel, action log, state snapshot).
- Predictability helps onboarding in large team
Signals
- Much less boilerplate, fewer files, faster iteration.
- Highly readable logic colocated with the component/feature.
- You add structure as you need it (not by default).
Rule of thumb:
- If your team struggles with consistency, NgRx gives you a framework to align on.
- If you need to move fast with simple state, Signals let you ship quickly with excellent DX.
3) Performance & Change Detection
- Signals are fine-grained: when a signal changes, only the consumers re-compute/re-render. Pair with
OnPushand you can avoid unnecessary change detection. - NgRx updates a store slice; with memoized selectors and
OnPush, only the components that select changed slices re-render. - For massive lists/components, Signals often feel snappier because updates are hyper-targeted.
General tips (applies to both):
- Use
OnPushchange detection. - Split large state into smaller feature slices.
trackByin*ngFor.- Avoid giant selectors; compose small ones.
- Profile with Angular DevTools and browser Performance tab.
4) Interop with RxJS (Angular 17)
Angular offers first-class interop helpers in @angular/core/rxjs-interop:
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// RxJS Observable -> Signal
const userSignal = toSignal(this.userService.user$);
// Signal -> RxJS Observable
const user$ = toObservable(userSignal);
This makes hybrid strategies trivial: keep your existing RxJS streams (NgRx effects/selectors) while gradually adopting Signals where they add value.
5) When to choose which (decision guide)
Choose NgRx if you need:
- Large cross-feature domain state with complex mutations.
- Auditing, time-travel, strict action history.
- Team governance and patterns that scale across squads.
- Effects orchestration for advanced async logic.
Choose Signals if you need:
- Local or view state within a feature/component.
- Super low boilerplate and speed of delivery.
- Fine-grained performance for interactive UIs.
- Easy state co-location and simple mental model.
Common winning combo:
- Signals (or NgRx ComponentStore) for feature/local state.
- NgRx store for app-wide domain state and complex effects.
6) Hybrid patterns that work
- Signals for UI, NgRx for domain:
Keep checkout/cart/orders in NgRx; keep “filters, toggles, dialogs, wizards” as signals close to the components. - NgRx ComponentStore as a bridge:
ComponentStore provides lightweight, testable stores with RxJS. You can interop with Signals viatoSignal()andtoObservable(). - Signal-backed selectors in components:
Derive computed values viacomputed()close to the consumer, even if the source comes from NgRx selectors.
7) Migration tips (without big-bang rewrites)
- Start inside one feature module. Replace local NgRx slices with a signal-based store, keep app-wide state in NgRx.
- Keep your actions and selectors stable while you move the internal storage for that slice.
- Use
toSignal()on existingselect(...)observables to adopt fine-grained rendering without changing the source of truth. - Add tests around critical flows before and after to validate behavior.
8) Minimal examples
A. Tiny NgRx counter
actions.ts
import { createAction, props } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const setCount = createAction('[Counter] Set', props<{ value: number }>());
reducer.ts
import { createReducer, on } from '@ngrx/store';
import { increment, setCount } from './actions';
export interface CounterState { count: number; }
export const initialState: CounterState = { count: 0 };
export const counterReducer = createReducer(
initialState,
on(increment, state => ({ ...state, count: state.count + 1 })),
on(setCount, (state, { value }) => ({ ...state, count: value }))
);
selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CounterState } from './reducer';
export const selectCounter = createFeatureSelector<CounterState>('counter');
export const selectCount = createSelector(selectCounter, s => s.count);
component.ts
count$ = this.store.select(selectCount);
constructor(private store: Store) {}
inc() { this.store.dispatch(increment()); }
B. Tiny Signals store
counter.store.ts
import { Injectable, signal, computed, effect } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterStore {
private readonly _count = signal(0);
readonly count = this._count.asReadonly();
readonly double = computed(() => this._count() * 2);
inc = () => this._count.update(c => c + 1);
set = (v: number) => this._count.set(v);
// Example side-effect
log = effect(() => {
console.debug('count is', this._count());
});
}
component.ts
constructor(public counter: CounterStore) {}
template.html
<p>Count: {{ counter.count() }} | Double: {{ counter.double() }}</p>
<button (click)="counter.inc()">Increment</button>
9) FAQ
Q: Are Signals replacing NgRx?
A: No. They solve different problems. Signals excel at local, fine-grained state; NgRx excels at global domain state with strong tooling and patterns.
Q: What about server data and caching?
A: Use dedicated data-fetching libraries (or Effects + services). Model the cache in NgRx if multiple features share it; keep per-view cache in a signal store.
Q: Can I use Signals with RxJS?
A: Yes—use toSignal() and toObservable() to bridge seamlessly.
Q: Is there a middle ground without the full NgRx store?
A: Yes, NgRx ComponentStore offers a lightweight, testable store with RxJS. Combine it with Signals for rendering.
10) Wrap-up
- If you’re optimizing for speed and simplicity on a feature or page, Signals will get you there with minimal code and excellent performance.
- If you’re optimizing for predictability, audits, and scaling across teams, NgRx provides the structure and tooling you need.
- In Angular 17, the best answer is often both: Signals where the UI needs to be nimble, NgRx where the business domain needs to be robust.
Quick checklist
- Decide per feature: local UI state → Signals; cross-cutting domain → NgRx.
- Use
OnPush, memoized selectors,trackBy. - Keep selectors small and composable.
- Profile critical flows with Angular DevTools.
- Bridge RxJS & Signals via
toSignal()/toObservable(). - Document your chosen patterns in a short team playbook.

