NgRx vs Signals in Angular 17 — NgRx with selectors/effects/reducers vs Signals with fine-grained reactivity and less boilerplate

NgRx or Signals? State-Management Decisions in Angular 17

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.

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 OnPush and 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 OnPush change detection.
  • Split large state into smaller feature slices.
  • trackBy in *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

  1. Signals for UI, NgRx for domain:
    Keep checkout/cart/orders in NgRx; keep “filters, toggles, dialogs, wizards” as signals close to the components.
  2. NgRx ComponentStore as a bridge:
    ComponentStore provides lightweight, testable stores with RxJS. You can interop with Signals via toSignal() and toObservable().
  3. Signal-backed selectors in components:
    Derive computed values via computed() 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 existing select(...) 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.

Leave a Comment

Your email address will not be published. Required fields are marked *