๐ 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
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,withHookshave 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/signalsdevtools integration) - Modular and extensible โ custom features via
signalStoreFeature - No actions, reducers, or effects needed โ dramatically less boilerplate than classic NgRx
patchStateis 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
asyncpipe, 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. ๐