Docs
/
Angular
Chapter 10

10 — State Management

State Management Options in Angular

ApproachComplexityBest For
Component stateNoneLocal UI state
Service + BehaviorSubjectLowSmall-medium apps
Signals (Angular 16+)LowReactive local/shared state
NgRx SignalStoreMediumFeature-level state
NgRx StoreHighLarge 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 SizeState 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 async pipe
  • 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)