Skip to main content

Command Palette

Search for a command to run...

๐Ÿ›’ Angular State Management in 2026: Plain Services vs NgRx Signal Store vs NgRx

Most Angular apps fail not because of missing features โ€” but because of the wrong level of abstraction. Here's an honest, code-first comparison of all three state management approaches using the same real-world shopping cart

Published
โ€ข13 min read
๐Ÿ›’ Angular State Management in 2026: Plain Services vs NgRx Signal Store vs NgRx
T

Hi ๐Ÿ‘‹, I'm Tushar Patil. Currently I am working as Frontend Developer (Angular) and also have expertise with .Net Core and Framework.


๐Ÿค” Why is this even a question?

A few years ago the answer was simple: "use NgRx for serious apps." Today it's more nuanced. Angular Signals changed the game. You can now build reactive, scalable state management with zero third-party libraries โ€” or you can reach for NgRx Signal Store, a lightweight signals-first alternative โ€” or you can use classic NgRx when you genuinely need it.

The problem is that most tutorials recommend NgRx for everything, which leads teams to write actions, reducers, and effects for a five-field shopping cart. That's like using a sledgehammer to crack a nut. ๐Ÿ”จ

This post cuts through the noise with a concrete example: a shopping cart with add, remove, quantity update, and a computed total. We'll build it all three ways and let the code speak for itself.


๐Ÿ—บ๏ธ The playing field: what we're building

Our shopping cart needs to:

  • ๐Ÿ›๏ธ Hold a list of cart items (product + quantity)
  • โž• Add an item (or increase quantity if already in cart)
  • โž– Remove an item
  • ๐Ÿ”ข Update quantity for an item
  • ๐Ÿ’ฐ Compute total price (derived state)
  • โณ Track a loading state for async operations
  • ๐Ÿ“ก Fetch cart from an API on init

Same requirements, three solutions.


๐ŸŸข Approach 1: Plain Services with Signals

When to use it โœ…

Small to medium apps, feature-level state, teams new to Signals, no need for DevTools or time-travel debugging.

The implementation

// cart.service.ts
import { Injectable, computed, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export interface CartItem {
  productId: number;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  // ๐Ÿ“ฆ Private state โ€” only mutated inside this service
  private readonly _items   = signal<CartItem[]>([]);
  private readonly _loading = signal(false);

  // ๐Ÿ“– Public read-only access
  readonly items   = this._items.asReadonly();
  readonly loading = this._loading.asReadonly();

  // ๐Ÿงฎ Derived state โ€” recomputes automatically
  readonly totalPrice = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0)
  );

  constructor(private http: HttpClient) {}

  // ๐Ÿ“ก Load cart from API
  loadCart(): void {
    this._loading.set(true);
    this.http.get<CartItem[]>('/api/cart').subscribe({
      next: (items) => {
        this._items.set(items);
        this._loading.set(false);
      },
      error: () => this._loading.set(false),
    });
  }

  // โž• Add item โ€” or increment quantity if already in cart
  addItem(item: Omit<CartItem, 'quantity'>): void {
    this._items.update(items => {
      const existing = items.find(i => i.productId === item.productId);
      if (existing) {
        return items.map(i =>
          i.productId === item.productId
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...items, { ...item, quantity: 1 }];
    });
  }

  // ๐Ÿ”ข Update quantity for a specific item
  updateQuantity(productId: number, quantity: number): void {
    if (quantity <= 0) {
      this.removeItem(productId);
      return;
    }
    this._items.update(items =>
      items.map(i => i.productId === productId ? { ...i, quantity } : i)
    );
  }

  // โž– Remove item from cart
  removeItem(productId: number): void {
    this._items.update(items =>
      items.filter(i => i.productId !== productId)
    );
  }

  // ๐Ÿงน Clear the entire cart
  clearCart(): void {
    this._items.set([]);
  }
}

Template usage

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    @if (cart.loading()) {
      <p>โณ Loading cart...</p>
    }

    <p>{{ cart.itemCount() }} items ยท Total: {{ cart.totalPrice() | currency }}</p>

    @for (item of cart.items(); track item.productId) {
      <div class="cart-item">
        <span>{{ item.name }}</span>
        <button (click)="cart.updateQuantity(item.productId, item.quantity - 1)">โˆ’</button>
        <span>{{ item.quantity }}</span>
        <button (click)="cart.updateQuantity(item.productId, item.quantity + 1)">+</button>
        <button (click)="cart.removeItem(item.productId)">๐Ÿ—‘๏ธ</button>
      </div>
    }
  `,
})
export class CartComponent {
  cart = inject(CartService);

  ngOnInit() {
    this.cart.loadCart();
  }
}

๐Ÿ‘ Pros

  • Zero dependencies โ€” no extra packages needed
  • Easy to test with TestBed
  • Immediately familiar to any Angular developer
  • Works perfectly with zoneless and SSR

๐Ÿ‘Ž Cons

  • No built-in DevTools support โ€” harder to debug state changes
  • State mutation logic lives in the service methods with no audit trail
  • Can get messy as complexity grows (nested state, optimistic updates, cache invalidation)
  • No standardised pattern โ€” every team does it slightly differently

๐Ÿ”ต Approach 2: NgRx Signal Store

When to use it โœ…

Medium to large apps, feature stores, teams already using Signals, want structure without full NgRx ceremony.

Installation

npm install @ngrx/signals

The implementation

The signalStore function accepts a sequence of store features as input. Each feature can contribute state slices, computed signals, and methods to the resulting store.

// cart.store.ts
import { computed } from '@angular/core';
import {
  patchState,
  signalStore,
  withComputed,
  withHooks,
  withMethods,
  withState,
} from '@ngrx/signals';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export interface CartItem {
  productId: number;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  loading: boolean;
}

const initialState: CartState = {
  items: [],
  loading: false,
};

export const CartStore = signalStore(
  { providedIn: 'root' },

  // 1๏ธโƒฃ Define state โ€” each property becomes a Signal automatically
  withState(initialState),

  // 2๏ธโƒฃ Derived state โ€” computed signals
  withComputed(({ items }) => ({
    totalPrice: computed(() =>
      items().reduce((sum, item) => sum + item.price * item.quantity, 0)
    ),
    itemCount: computed(() =>
      items().reduce((sum, item) => sum + item.quantity, 0)
    ),
    isEmpty: computed(() => items().length === 0),
  })),

  // 3๏ธโƒฃ Methods โ€” mutation logic with patchState
  withMethods((store, http = inject(HttpClient)) => ({

    loadCart(): void {
      patchState(store, { loading: true });
      http.get<CartItem[]>('/api/cart').subscribe({
        next: (items) => patchState(store, { items, loading: false }),
        error: ()    => patchState(store, { loading: false }),
      });
    },

    addItem(item: Omit<CartItem, 'quantity'>): void {
      const existing = store.items().find(i => i.productId === item.productId);
      if (existing) {
        patchState(store, {
          items: store.items().map(i =>
            i.productId === item.productId
              ? { ...i, quantity: i.quantity + 1 }
              : i
          ),
        });
      } else {
        patchState(store, {
          items: [...store.items(), { ...item, quantity: 1 }],
        });
      }
    },

    updateQuantity(productId: number, quantity: number): void {
      if (quantity <= 0) {
        patchState(store, {
          items: store.items().filter(i => i.productId !== productId),
        });
        return;
      }
      patchState(store, {
        items: store.items().map(i =>
          i.productId === productId ? { ...i, quantity } : i
        ),
      });
    },

    removeItem(productId: number): void {
      patchState(store, {
        items: store.items().filter(i => i.productId !== productId),
      });
    },

    clearCart(): void {
      patchState(store, { items: [] });
    },
  })),

  // 4๏ธโƒฃ Hooks โ€” lifecycle management
  withHooks({
    onInit(store) {
      store.loadCart(); // ๐Ÿš€ auto-load cart on initialisation
    },
  })
);

Template usage

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    @if (store.loading()) {
      <p>โณ Loading cart...</p>
    }

    <p>{{ store.itemCount() }} items ยท Total: {{ store.totalPrice() | currency }}</p>

    @for (item of store.items(); track item.productId) {
      <div class="cart-item">
        <span>{{ item.name }}</span>
        <button (click)="store.updateQuantity(item.productId, item.quantity - 1)">โˆ’</button>
        <span>{{ item.quantity }}</span>
        <button (click)="store.updateQuantity(item.productId, item.quantity + 1)">+</button>
        <button (click)="store.removeItem(item.productId)">๐Ÿ—‘๏ธ</button>
      </div>
    }
  `,
})
export class CartComponent {
  store = inject(CartStore);
}

Notice two things: the template is nearly identical to the plain service approach, and there's no ngOnInit because withHooks handles it inside the store. ๐ŸŽฏ

๐Ÿ‘ Pros

  • Clean, structured API โ€” withState, withComputed, withMethods, withHooks have clear responsibilities
  • Can be provided either globally or at component level โ€” combining the best of NgRx Global Store and Component Store
  • DevTools support (with @ngrx/signals devtools integration)
  • Modular and extensible โ€” custom features via signalStoreFeature
  • No actions, reducers, or effects needed โ€” dramatically less boilerplate than classic NgRx
  • patchState is type-safe and immutable

๐Ÿ‘Ž Cons

  • Extra dependency (@ngrx/signals)
  • Less structure than classic NgRx โ€” no enforced audit trail of state changes
  • Relatively new โ€” fewer community examples than classic NgRx
  • Not ideal for complex async flows (sagas, complex effects chains) โ€” RxJS integration requires rxMethod

๐Ÿ”ด Approach 3: Classic NgRx (Redux pattern)

When to use it โœ…

Large enterprise apps, complex async flows, teams needing strict auditability, apps where time-travel debugging matters, applications with multiple interacting feature slices.

Installation

npm install @ngrx/store @ngrx/effects

The implementation โ€” all the pieces

Classic NgRx requires four files for the same cart. Let's look at each one.

1. State + Actions:

// cart.actions.ts
import { createAction, props } from '@ngrx/store';
import { CartItem } from './cart.models';

export const loadCart        = createAction('[Cart] Load Cart');
export const loadCartSuccess = createAction('[Cart] Load Cart Success',
  props<{ items: CartItem[] }>());
export const loadCartFailure = createAction('[Cart] Load Cart Failure',
  props<{ error: string }>());

export const addItem         = createAction('[Cart] Add Item',
  props<{ item: Omit<CartItem, 'quantity'> }>());
export const removeItem      = createAction('[Cart] Remove Item',
  props<{ productId: number }>());
export const updateQuantity  = createAction('[Cart] Update Quantity',
  props<{ productId: number; quantity: number }>());
export const clearCart       = createAction('[Cart] Clear Cart');

2. Reducer:

// cart.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as CartActions from './cart.actions';
import { CartItem } from './cart.models';

export interface CartState {
  items: CartItem[];
  loading: boolean;
  error: string | null;
}

const initialState: CartState = {
  items: [],
  loading: false,
  error: null,
};

export const cartReducer = createReducer(
  initialState,

  on(CartActions.loadCart, state => ({ ...state, loading: true })),

  on(CartActions.loadCartSuccess, (state, { items }) => ({
    ...state, items, loading: false, error: null,
  })),

  on(CartActions.loadCartFailure, (state, { error }) => ({
    ...state, loading: false, error,
  })),

  on(CartActions.addItem, (state, { item }) => {
    const existing = state.items.find(i => i.productId === item.productId);
    return {
      ...state,
      items: existing
        ? state.items.map(i =>
            i.productId === item.productId
              ? { ...i, quantity: i.quantity + 1 }
              : i
          )
        : [...state.items, { ...item, quantity: 1 }],
    };
  }),

  on(CartActions.updateQuantity, (state, { productId, quantity }) => ({
    ...state,
    items: quantity <= 0
      ? state.items.filter(i => i.productId !== productId)
      : state.items.map(i =>
          i.productId === productId ? { ...i, quantity } : i
        ),
  })),

  on(CartActions.removeItem, (state, { productId }) => ({
    ...state,
    items: state.items.filter(i => i.productId !== productId),
  })),

  on(CartActions.clearCart, state => ({ ...state, items: [] })),
);

3. Selectors:

// cart.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CartState } from './cart.reducer';

const selectCartState = createFeatureSelector<CartState>('cart');

export const selectItems     = createSelector(selectCartState, s => s.items);
export const selectLoading   = createSelector(selectCartState, s => s.loading);

export const selectTotalPrice = createSelector(selectItems, items =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export const selectItemCount = createSelector(selectItems, items =>
  items.reduce((sum, item) => sum + item.quantity, 0)
);

4. Effects:

// cart.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { catchError, map, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import * as CartActions from './cart.actions';
import { CartItem } from './cart.models';

@Injectable()
export class CartEffects {
  loadCart$ = createEffect(() =>
    this.actions$.pipe(
      ofType(CartActions.loadCart),
      switchMap(() =>
        this.http.get<CartItem[]>('/api/cart').pipe(
          map(items    => CartActions.loadCartSuccess({ items })),
          catchError(e => of(CartActions.loadCartFailure({ error: e.message })))
        )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private http: HttpClient,
  ) {}
}

5. Template usage:

@Component({
  selector: 'app-cart',
  standalone: true,
  imports: [AsyncPipe, CurrencyPipe],
  template: `
    @if (loading$ | async) {
      <p>โณ Loading cart...</p>
    }

    <p>{{ itemCount\( | async }} items ยท Total: {{ totalPrice\) | async | currency }}</p>

    @for (item of items$ | async; track item.productId) {
      <div class="cart-item">
        <span>{{ item.name }}</span>
        <button (click)="updateQty(item.productId, item.quantity - 1)">โˆ’</button>
        <span>{{ item.quantity }}</span>
        <button (click)="updateQty(item.productId, item.quantity + 1)">+</button>
        <button (click)="remove(item.productId)">๐Ÿ—‘๏ธ</button>
      </div>
    }
  `,
})
export class CartComponent implements OnInit {
  private store = inject(Store);

  items$      = this.store.select(selectItems);
  loading$    = this.store.select(selectLoading);
  totalPrice$ = this.store.select(selectTotalPrice);
  itemCount$  = this.store.select(selectItemCount);

  ngOnInit() {
    this.store.dispatch(CartActions.loadCart());
  }

  updateQty(productId: number, quantity: number) {
    this.store.dispatch(CartActions.updateQuantity({ productId, quantity }));
  }

  remove(productId: number) {
    this.store.dispatch(CartActions.removeItem({ productId }));
  }
}

๐Ÿ‘ Pros

  • Complete audit trail โ€” every state change is a named, serializable action
  • Time-travel debugging with Redux DevTools
  • Predictable, enforced unidirectional data flow
  • Excellent for complex async chains (effects with retries, cancellation, optimistic updates)
  • Strong community, battle-tested in enterprise apps for years
  • Best-in-class testing tools โ€” actions and reducers are pure functions

๐Ÿ‘Ž Cons

  • Significant boilerplate โ€” 4 files for one feature
  • Steep learning curve โ€” actions, reducers, selectors, effects, and the Redux mental model all need to click
  • Overkill for simple state โ€” a shopping cart doesn't need audit trails
  • Observables everywhere โ€” templates need async pipe, no direct signal access in the classic API

โš”๏ธ Side-by-side comparison

Feature Plain Services NgRx Signal Store Classic NgRx
Dependencies None @ngrx/signals @ngrx/store + @ngrx/effects
Boilerplate Low Medium High
Learning curve Low ๐ŸŸข Medium ๐ŸŸก High ๐Ÿ”ด
Template syntax Signals Signals Observables + async pipe
Computed/derived state computed() withComputed() Selectors
Async side effects Manual withMethods() / rxMethod Effects
DevTools โŒ No โœ… Yes (with plugin) โœ… Yes (Redux DevTools)
Audit trail โŒ No โš ๏ธ Partial โœ… Full
Time-travel debugging โŒ No โŒ No โœ… Yes
Component-level stores โœ… Yes โœ… Yes โš ๏ธ Complex
Scales to enterprise โš ๏ธ With discipline โœ… Yes โœ… Yes
Files for our cart 1 1 5+

๐Ÿšฆ Which one should you use?

Is your app small or a single feature module?
  โ””โ”€โ”€ Use plain services with signals โœ…

Does your app have shared state across many features, or are you on NgRx already?
  โ””โ”€โ”€ Are you already comfortable with Signals?
        โ”œโ”€โ”€ Yes โ†’ NgRx Signal Store โœ…
        โ””โ”€โ”€ No  โ†’ Classic NgRx is still safe and familiar โœ…

Does your app require strict auditability, complex async chains,
or large teams needing enforced patterns?
  โ””โ”€โ”€ Classic NgRx โœ…

Are you starting a new feature in an existing NgRx codebase?
  โ””โ”€โ”€ Consider NgRx Signal Store for the new feature โ€” they can coexist โœ…

๐Ÿ’ก Classic NgRx and Signal Store solve the same problem but rely on different mental models and trade-offs. Both can coexist within the same Angular application and are officially supported by the NgRx team.


๐ŸŽฏ Final verdict for 2026

If you're starting a new Angular 21 project, the recommendation is clear:

  • Small/medium app or isolated feature โ†’ Plain services with signals. Zero overhead, fully signal-native.
  • Medium/large app that needs structure โ†’ NgRx Signal Store. The sweet spot between simplicity and scalability. Think of it as a mini NgRx store but without reducers, actions, or effects โ€” just plain signals with some helpers.
  • Enterprise app with strict requirements โ†’ Classic NgRx. The audit trail, DevTools, and enforced patterns are worth the boilerplate at scale.

The mistake to avoid is choosing Classic NgRx by default because it's what you've always used. The Angular ecosystem in 2026 gives you better, lighter options for the vast majority of use cases. ๐Ÿš€


๐Ÿ“š Further reading