State Management Showdown: NgRx vs Signals vs Services in Angular

Angular has three state management approaches and choosing wrong means either over-engineering a todo app or under-engineering an enterprise dashboard. Compare NgRx, Signals, and plain services with real examples and a decision framework.

State Management Showdown: NgRx vs Signals vs Services in Angular illustration
On this page7 sections

Every Angular team eventually debates state management. One developer wants NgRx for “proper architecture.” Another says signals make everything simpler. A third argues that injectable services with BehaviorSubjects work fine. They are all right — for different scenarios.

The Three Approaches

1. Simple Services (BehaviorSubject / Signals)

// Simplest approach: service with signals
@Injectable({ providedIn: 'root' })
export class CartService {
  private _items = signal<CartItem[]>([]);

  readonly items = this._items.asReadonly();
  readonly total = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.qty, 0)
  );
  readonly count = computed(() =>
    this._items().reduce((sum, item) => sum + item.qty, 0)
  );

  addItem(product: Product) {
    this._items.update(items => {
      const existing = items.find(i => i.productId === product.id);
      if (existing) {
        return items.map(i =>
          i.productId === product.id ? { ...i, qty: i.qty + 1 } : i
        );
      }
      return [...items, { productId: product.id, name: product.name, price: product.price, qty: 1 }];
    });
  }

  removeItem(productId: string) {
    this._items.update(items => items.filter(i => i.productId !== productId));
  }

  clear() {
    this._items.set([]);
  }
}

// Component usage: dead simple
@Component({
  template: \`
    <span>Cart ({{ cart.count() }})</span>
    <span>Total: {{ cart.total() | currency }}</span>
  \`
})
export class CartBadgeComponent {
  cart = inject(CartService);
}

2. NgRx Store (Redux Pattern)

// Actions: what happened
export const CartActions = createActionGroup({
  source: 'Cart',
  events: {
    'Add Item': props<{ product: Product }>(),
    'Remove Item': props<{ productId: string }>(),
    'Clear Cart': emptyProps(),
    'Load Cart Success': props<{ items: CartItem[] }>(),
    'Load Cart Failure': props<{ error: string }>(),
  },
});

// Reducer: how state changes
export const cartReducer = createReducer(
  initialState,
  on(CartActions.addItem, (state, { product }) => ({
    ...state,
    items: addOrIncrement(state.items, product),
  })),
  on(CartActions.removeItem, (state, { productId }) => ({
    ...state,
    items: state.items.filter(i => i.productId !== productId),
  })),
  on(CartActions.clearCart, () => initialState),
  on(CartActions.loadCartSuccess, (state, { items }) => ({
    ...state,
    items,
    loaded: true,
  })),
);

// Selectors: derived state
export const selectCartItems = createSelector(selectCart, state => state.items);
export const selectCartTotal = createSelector(selectCartItems, items =>
  items.reduce((sum, i) => sum + i.price * i.qty, 0)
);

// Effects: side effects (API calls)
@Injectable()
export class CartEffects {
  loadCart$ = createEffect(() =>
    this.actions$.pipe(
      ofType(CartActions.loadCart),
      switchMap(() =>
        this.cartApi.load().pipe(
          map(items => CartActions.loadCartSuccess({ items })),
          catchError(error => of(CartActions.loadCartFailure({ error: error.message })))
        )
      )
    )
  );

  constructor(private actions$: Actions, private cartApi: CartApiService) {}
}

// Component usage
@Component({
  template: \`
    <span>Total: {{ total$ | async | currency }}</span>
  \`
})
export class CartBadgeComponent {
  total$ = this.store.select(selectCartTotal);
  constructor(private store: Store) {}
}

3. Signal Store (NgRx Signals)

// NgRx SignalStore: NgRx concepts with signal ergonomics
export const CartStore = signalStore(
  { providedIn: 'root' },
  withState<CartState>({
    items: [],
    loaded: false,
    error: null,
  }),
  withComputed(({ items }) => ({
    total: computed(() => items().reduce((sum, i) => sum + i.price * i.qty, 0)),
    count: computed(() => items().reduce((sum, i) => sum + i.qty, 0)),
  })),
  withMethods((store, cartApi = inject(CartApiService)) => ({
    addItem(product: Product) {
      patchState(store, { items: addOrIncrement(store.items(), product) });
    },
    removeItem(productId: string) {
      patchState(store, { items: store.items().filter(i => i.productId !== productId) });
    },
    async loadCart() {
      try {
        const items = await firstValueFrom(cartApi.load());
        patchState(store, { items, loaded: true });
      } catch (e) {
        patchState(store, { error: 'Failed to load cart' });
      }
    },
  })),
);

// Component usage
@Component({
  template: \`
    <span>Cart ({{ store.count() }})</span>
    <span>Total: {{ store.total() | currency }}</span>
  \`
})
export class CartBadgeComponent {
  store = inject(CartStore);
}

Comparison Table

Criteria Services + Signals NgRx Store NgRx SignalStore
Boilerplate Minimal High (actions, reducers, effects, selectors) Medium
Learning Curve Low High (Redux concepts) Medium
DevTools None Excellent (time-travel debugging) Limited
Predictability Good Excellent (strict unidirectional flow) Good
Testing Easy (just call methods) Structured (test reducers, effects separately) Easy
Async Handling Manual (RxJS in service) Effects (declarative) Methods (imperative async)
Team Scale Small (1-5 devs) Large (10+ devs, enforced patterns) Medium (5-10 devs)

Decision Framework

  • Small app, small team (1-5 devs): Services with signals. Simple, fast, no overhead.
  • Medium app, medium team (5-10 devs): NgRx SignalStore. Structured patterns without Redux verbosity.
  • Large enterprise app (10+ devs): NgRx Store. Enforced architecture, time-travel debugging, established patterns.
  • State shared across many components: Any centralized store (NgRx or signal service at root).
  • State local to one component: Just use a signal in the component. No store needed.
  • Complex async workflows: NgRx Effects or RxJS in services. Signals alone do not handle streams.

Key Takeaways

  • Start with the simplest approach that works — signal services for most apps
  • NgRx Store earns its complexity at scale — with large teams, the enforced patterns prevent chaos
  • NgRx SignalStore is the middle ground — structured state management without Redux boilerplate
  • Local state stays local — not everything belongs in a global store
  • You can mix approaches — global auth state in a service, feature state in NgRx, component state in signals
  • The best state management is the one your team understands — a well-used simple approach beats a misused complex one

State management is a spectrum, not a binary choice. Match the tool to the problem: signals for simple state, signal stores for moderate complexity, full NgRx for enterprise-scale applications with strict architectural requirements. The goal is managing complexity, not adding it.

Share this article

Stuck on implementation?

Get private, 1-on-1 help with system design, performance, scaling, or any technical challenge.

Book a Session

Related Production Resources

Course

Free learning tracks

Turn this guide into a structured production engineering path.

Lab

Interactive engineering labs

Practice the same ideas through scenario-based simulators.

Reference

Production cheatsheets

Keep the operational commands and checks nearby.

Glossary

Key terms

Review the vocabulary behind the architecture.

Discussion

Questions, corrections, or production notes? Add them here so other learners can benefit.

Continue Reading

Related practical guides from the same production engineering path.

Frontend 12 min read

Web Authentication API: Passwordless Login with Passkeys

Passwords are the weakest link in security. Passkeys replace them with biometrics and device-bound credentials. Learn WebAuthn, FIDO2, and how to implement passwordless login in your web app with real code examples.

WebAuthn Passkeys
Frontend 12 min read

Micro-Frontends: Splitting a Monolith UI Without the Pain

Your frontend monolith has 200 components, 15 teams, and deploys take 45 minutes. Micro-frontends let teams ship independently. Learn Module Federation, shared dependencies, routing strategies, and when NOT to use them.

Micro-Frontends Module Federation