# 🛒 Angular State Management in 2026: Plain Services vs NgRx Signal Store vs NgRx

---

## 🤔 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

```typescript
// 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

```typescript
@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

```bash
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.

```typescript
// 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

```typescript
@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

```bash
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:**

```typescript
// 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:**

```typescript
// 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:**

```typescript
// 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:**

```typescript
// 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:**

```typescript
@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

- 📖 [NgRx Signal Store official docs](https://ngrx.io/guide/signals/signal-store)
- ⚡ [Getting started with Angular Signals](https://blog.techtush.in/getting-started-with-angular-signals-a-beginner-s-guide)
- 🔗 [linkedSignal and resource() guide](https://blog.techtush.in/angular-s-linkedsignal-and-resource-a-practical-guide-for-2026)
- 🏗️ [NgRx official docs](https://ngrx.io/guide/store)
