Docs
/
Angular
Chapter 10
10 — State Management
State Management Options in Angular
| Approach | Complexity | Best For |
|---|---|---|
| Component state | None | Local UI state |
| Service + BehaviorSubject | Low | Small-medium apps |
| Signals (Angular 16+) | Low | Reactive local/shared state |
| NgRx SignalStore | Medium | Feature-level state |
| NgRx Store | High | Large enterprise apps |
1. Component State (Simplest)
@Component({ ... })
export class CounterComponent {
count = 0;
increment() { this.count++; }
decrement() { this.count--; }
reset() { this.count = 0; }
}
2. Service + BehaviorSubject
@Injectable({ providedIn: 'root' })
export class CartService {
private itemsSubject = new BehaviorSubject<CartItem[]>([]);
items$ = this.itemsSubject.asObservable();
// Derived state
total$ = this.items$.pipe(
map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0)),
);
count$ = this.items$.pipe(map(items => items.length));
addItem(item: CartItem) {
const current = this.itemsSubject.value;
const existing = current.find(i => i.id === item.id);
if (existing) {
this.itemsSubject.next(
current.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i),
);
} else {
this.itemsSubject.next([...current, { ...item, qty: 1 }]);
}
}
removeItem(id: string) {
this.itemsSubject.next(this.itemsSubject.value.filter(i => i.id !== id));
}
clear() {
this.itemsSubject.next([]);
}
}
<!-- component template -->
<p>Items: {{ cartService.count$ | async }}</p>
<p>Total: {{ cartService.total$ | async | currency }}</p>
3. Signals (Angular 16+)
Signals are Angular's built-in reactive primitive — simpler than RxJS for synchronous state.
import { signal, computed, effect } from '@angular/core';
@Component({
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
count = signal(0); // Writable signal
double = computed(() => this.count() * 2); // Derived (read-only)
constructor() {
// Side effect — runs when dependencies change
effect(() => {
console.log('Count changed to:', this.count());
});
}
increment() {
this.count.update(c => c + 1); // Functional update
// Or: this.count.set(5); // Set directly
}
}
Signal-Based Service
@Injectable({ providedIn: 'root' })
export class CartService {
// Private writable signal
private items = signal<CartItem[]>([]);
// Public read-only computed signals
readonly cartItems = this.items.asReadonly();
readonly total = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
readonly count = computed(() => this.items().length);
readonly isEmpty = computed(() => this.items().length === 0);
addItem(item: CartItem) {
this.items.update(items => {
const existing = items.find(i => i.id === item.id);
if (existing) {
return items.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i);
}
return [...items, { ...item, qty: 1 }];
});
}
removeItem(id: string) {
this.items.update(items => items.filter(i => i.id !== id));
}
clear() {
this.items.set([]);
}
}
<!-- No async pipe needed with signals! -->
<p>Items: {{ cartService.count() }}</p>
<p>Total: {{ cartService.total() | currency }}</p>
4. NgRx SignalStore (Medium Complexity)
npm install @ngrx/signals
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
interface ProductState {
products: Product[];
loading: boolean;
error: string | null;
filter: string;
}
const initialState: ProductState = {
products: [],
loading: false,
error: null,
filter: '',
};
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ products, filter }) => ({
filteredProducts: computed(() => {
const f = filter().toLowerCase();
return f
? products().filter(p => p.name.toLowerCase().includes(f))
: products();
}),
count: computed(() => products().length),
})),
withMethods((store, productService = inject(ProductService)) => ({
loadProducts: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() =>
productService.getAll().pipe(
tapResponse({
next: (products) => patchState(store, { products, loading: false }),
error: (err: Error) => patchState(store, { error: err.message, loading: false }),
}),
),
),
),
),
setFilter(filter: string) {
patchState(store, { filter });
},
removeProduct(id: string) {
patchState(store, {
products: store.products().filter(p => p.id !== id),
});
},
})),
);
// Usage in component
@Component({
providers: [ProductStore], // Or use providedIn: 'root'
template: `
<input (input)="store.setFilter($any($event.target).value)">
@if (store.loading()) {
<app-spinner />
}
@for (product of store.filteredProducts(); track product.id) {
<app-product-card [product]="product" />
}
`,
})
export class ProductListComponent implements OnInit {
readonly store = inject(ProductStore);
ngOnInit() {
this.store.loadProducts();
}
}
5. NgRx Store (Full Redux — Enterprise)
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools
Actions
import { createActionGroup, props, emptyProps } from '@ngrx/store';
export const ProductActions = createActionGroup({
source: 'Products',
events: {
'Load Products': emptyProps(),
'Load Products Success': props<{ products: Product[] }>(),
'Load Products Failure': props<{ error: string }>(),
'Delete Product': props<{ id: string }>(),
},
});
Reducer
import { createReducer, on } from '@ngrx/store';
export interface ProductState {
products: Product[];
loading: boolean;
error: string | null;
}
const initialState: ProductState = { products: [], loading: false, error: null };
export const productReducer = createReducer(
initialState,
on(ProductActions.loadProducts, (state) => ({ ...state, loading: true, error: null })),
on(ProductActions.loadProductsSuccess, (state, { products }) => ({ ...state, products, loading: false })),
on(ProductActions.loadProductsFailure, (state, { error }) => ({ ...state, error, loading: false })),
on(ProductActions.deleteProduct, (state, { id }) => ({
...state,
products: state.products.filter(p => p.id !== id),
})),
);
Effects
import { Actions, createEffect, ofType } from '@ngrx/effects';
@Injectable()
export class ProductEffects {
private actions$ = inject(Actions);
private productService = inject(ProductService);
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductActions.loadProducts),
switchMap(() =>
this.productService.getAll().pipe(
map(products => ProductActions.loadProductsSuccess({ products })),
catchError(err => of(ProductActions.loadProductsFailure({ error: err.message }))),
),
),
),
);
}
Selectors
import { createFeatureSelector, createSelector } from '@ngrx/store';
const selectProductState = createFeatureSelector<ProductState>('products');
export const selectAllProducts = createSelector(selectProductState, s => s.products);
export const selectLoading = createSelector(selectProductState, s => s.loading);
export const selectError = createSelector(selectProductState, s => s.error);
Component Usage
@Component({
template: `
@if (loading$ | async) { <app-spinner /> }
@for (p of products$ | async ?? []; track p.id) {
<app-product-card [product]="p" (delete)="delete(p.id)" />
}
`,
})
export class ProductListComponent implements OnInit {
private store = inject(Store);
products$ = this.store.select(selectAllProducts);
loading$ = this.store.select(selectLoading);
ngOnInit() {
this.store.dispatch(ProductActions.loadProducts());
}
delete(id: string) {
this.store.dispatch(ProductActions.deleteProduct({ id }));
}
}
When to Use What
| App Size | State Solution |
|---|---|
| Small (< 10 components) | Component state + services |
| Medium (10-50 components) | Signal-based services or NgRx SignalStore |
| Large enterprise (50+ components) | NgRx Store or NgRx SignalStore |
| Real-time data (WebSocket) | Service + Subject/Signal |
Key Takeaways
- Start simple — service + signals handles most apps
- Signals are synchronous, template-friendly, and don't need
asyncpipe - NgRx SignalStore is the modern middle ground — structured state without boilerplate
- NgRx Store (Redux) is for large teams who need strict patterns, DevTools, and time-travel debugging
- Don't use NgRx Store for small apps — the boilerplate isn't worth it
computed()signals are the equivalent of NgRx selectors- Always expose read-only state from services (
asReadonly()or selectors)