Skip to main content

Angular State Management

Choosing State Management

Angular offers multiple state management approaches: Services with RxJS for most cases, Signals for simple reactive state, and NgRx for complex enterprise apps. Start with Services + Signals and only add NgRx if state complexity demands it. Don't over-engineer - most applications don't need NgRx.

Overview

This guide covers Angular state management patterns using Services, Signals, and NgRx, with decision criteria for choosing the right approach. State management is about controlling how data flows through your application, where it lives, and how components access and modify it. The right choice depends on your application's complexity, team experience, and specific requirements.


Core Principles

  1. Services First: Start with stateful services for most state needs
  2. Signals for Reactive State: Use signals for simple synchronous state
  3. RxJS for Async: Observables for HTTP, WebSockets, time-based operations
  4. NgRx for Complex Apps: Large apps with many shared state interactions
  5. Local State in Components: Keep UI-only state local

Decision Tree

Choosing the right state management approach is critical for maintainability. The decision tree below provides a systematic way to evaluate your needs:

Decision criteria explained:

  • Component-local state: If state is only used within a single component (e.g., form input values, toggle states), keep it as a component property or signal. No need to lift it up.
  • Simple reactive state: For synchronous state shared across components (e.g., user preferences, UI themes), use a service with signals. Signals provide reactivity without RxJS complexity.
  • Async data: For data fetched from APIs, WebSockets, or any async source, use services with RxJS observables. RxJS provides powerful operators for handling async streams.
  • Complex state logic: If you have intricate state interactions, need time-travel debugging, or require a strict unidirectional data flow with action history, consider NgRx.

Services with RxJS

Services with RxJS are Angular's traditional and most flexible state management approach. A service acts as a centralized store that components inject and subscribe to. RxJS's BehaviorSubject maintains the current state value and emits updates to all subscribers. This pattern works well for most applications because it's simple, doesn't require additional libraries, and leverages Angular's built-in DI system.

The key pattern is to expose state as an observable (read-only) while keeping the subject (writable) private. This encapsulates state mutations within the service, preventing external code from directly modifying state.

Basic Stateful Service

// payment.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

export interface Payment {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
}

@Injectable({
providedIn: 'root'
})
export class PaymentService {
private http = inject(HttpClient);

// Private state
private paymentsSubject = new BehaviorSubject<Payment[]>([]);

// Public observable
payments$ = this.paymentsSubject.asObservable();

loadPayments(): Observable<Payment[]> {
return this.http.get<Payment[]>('/api/payments').pipe(
tap(payments => this.paymentsSubject.next(payments))
);
}

addPayment(payment: Omit<Payment, 'id'>): Observable<Payment> {
return this.http.post<Payment>('/api/payments', payment).pipe(
tap(newPayment => {
const current = this.paymentsSubject.value;
this.paymentsSubject.next([...current, newPayment]);
})
);
}

updatePaymentStatus(id: string, status: Payment['status']): void {
const current = this.paymentsSubject.value;
const updated = current.map(p =>
p.id === id ? { ...p, status } : p
);
this.paymentsSubject.next(updated);
}
}

Key patterns in this service:

  • BehaviorSubject: Unlike Subject, BehaviorSubject requires an initial value and always emits the most recent value to new subscribers. This ensures components always receive the current state immediately upon subscription.
  • .asObservable(): Exposes the stream as a read-only observable, preventing external code from calling .next() on the subject
  • tap() operator: Side effect operator that updates local state when HTTP responses arrive, keeping state in sync with server
  • Immutability: Using spread operator ([...current, newPayment]) creates new array references, which is essential for OnPush change detection

The service maintains a single source of truth. Components don't hold copies of the data - they subscribe to the service's observable and react to changes.

Using the Service

@Component({
selector: 'app-payment-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (payments$ | async; as payments) {
@for (payment of payments; track payment.id) {
<app-payment-card
[payment]="payment"
(statusChange)="updateStatus($event)"
/>
}
}
`
})
export class PaymentListComponent {
private paymentService = inject(PaymentService);

payments$ = this.paymentService.payments$;

ngOnInit(): void {
this.paymentService.loadPayments().subscribe();
}

updateStatus(event: { id: string; status: Payment['status'] }): void {
this.paymentService.updatePaymentStatus(event.id, event.status);
}
}

The component never directly modifies payment data. All mutations flow through the service, ensuring centralized control. The async pipe handles subscription and unsubscription automatically, preventing memory leaks.


Services with Signals

Signals offer a simpler, more lightweight alternative to RxJS for synchronous state management. Unlike observables, which are push-based (they push values to subscribers), signals are pull-based (they compute values when accessed). This makes them more efficient for frequently-read, infrequently-changed state.

Signal-based services are ideal for configuration, user preferences, filters, and other synchronous state that doesn't come from async sources. They integrate seamlessly with Angular's change detection - when a signal updates, Angular precisely knows which components to re-render, avoiding unnecessary checks.

Signal-Based Service

// payment-filter.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface PaymentFilters {
status: 'all' | 'pending' | 'completed' | 'failed';
currency: string;
minAmount: number;
maxAmount: number;
}

@Injectable({
providedIn: 'root'
})
export class PaymentFilterService {
// Writable signals
private statusSignal = signal<PaymentFilters['status']>('all');
private currencySignal = signal<string>('USD');
private minAmountSignal = signal<number>(0);
private maxAmountSignal = signal<number>(Infinity);

// Public read-only signals
status = this.statusSignal.asReadonly();
currency = this.currencySignal.asReadonly();
minAmount = this.minAmountSignal.asReadonly();
maxAmount = this.maxAmountSignal.asReadonly();

// Computed signal
hasActiveFilters = computed(() =>
this.statusSignal() !== 'all' ||
this.currencySignal() !== 'USD' ||
this.minAmountSignal() > 0 ||
this.maxAmountSignal() < Infinity
);

// Actions
setStatus(status: PaymentFilters['status']): void {
this.statusSignal.set(status);
}

setCurrency(currency: string): void {
this.currencySignal.set(currency);
}

setAmountRange(min: number, max: number): void {
this.minAmountSignal.set(min);
this.maxAmountSignal.set(max);
}

reset(): void {
this.statusSignal.set('all');
this.currencySignal.set('USD');
this.minAmountSignal.set(0);
this.maxAmountSignal.set(Infinity);
}
}

Signal service patterns:

  • Private writable signals: Keep signals writable internally (statusSignal)
  • Public readonly signals: Expose signals as readonly externally (.asReadonly()) to prevent external mutations
  • Computed signals: Automatically derive state from other signals (hasActiveFilters). Computed signals only recompute when their dependencies change, making them very efficient.
  • Action methods: Provide methods (setStatus, reset) for controlled state updates

Signals eliminate the need for observables when dealing with synchronous state, simplifying code and reducing overhead. No subscriptions to manage, no async pipe needed - signals work directly in templates.

Using Signal Service

@Component({
selector: 'app-payment-filters',
standalone: true,
imports: [FormsModule],
template: `
<div class="filters">
<select [(ngModel)]="selectedStatus" (change)="onStatusChange()">
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>

<input
type="text"
[(ngModel)]="selectedCurrency"
(change)="onCurrencyChange()"
placeholder="Currency"
/>

<button (click)="reset()" [disabled]="!filterService.hasActiveFilters()">
Reset Filters
</button>

@if (filterService.hasActiveFilters()) {
<span class="badge">Filters Active</span>
}
</div>
`
})
export class PaymentFiltersComponent {
filterService = inject(PaymentFilterService);

selectedStatus = this.filterService.status();
selectedCurrency = this.filterService.currency();

onStatusChange(): void {
this.filterService.setStatus(this.selectedStatus);
}

onCurrencyChange(): void {
this.filterService.setCurrency(this.selectedCurrency);
}

reset(): void {
this.filterService.reset();
this.selectedStatus = 'all';
this.selectedCurrency = 'USD';
}
}

Signals are called as functions in templates (filterService.hasActiveFilters()), and Angular automatically tracks dependencies. When any of the underlying signals change, the template updates automatically. The new control flow syntax (@if) works seamlessly with signals.


Combining Signals and RxJS

In real-world applications, you often need both signals (for UI state) and observables (for async data). Angular provides interoperability functions - toSignal() converts observables to signals, and toObservable() converts signals to observables. This allows you to leverage the strengths of each approach in a single service.

The hybrid pattern is particularly useful when you have async data (fetched via HTTP) that you want to expose as signals to components, or when you have signal-based filters that you need to use with RxJS operators like switchMap.

Hybrid Service Pattern

@Injectable({
providedIn: 'root'
})
export class PaymentStateService {
private http = inject(HttpClient);

// Signals for sync state
private selectedPaymentIdSignal = signal<string | null>(null);
selectedPaymentId = this.selectedPaymentIdSignal.asReadonly();

// RxJS for async data
private paymentsSubject = new BehaviorSubject<Payment[]>([]);
payments$ = this.paymentsSubject.asObservable();

// Computed: combine signal and observable
selectedPayment$ = toObservable(this.selectedPaymentId).pipe(
switchMap(id =>
id
? this.http.get<Payment>(`/api/payments/${id}`)
: of(null)
)
);

selectPayment(id: string): void {
this.selectedPaymentIdSignal.set(id);
}

loadPayments(): void {
this.http.get<Payment[]>('/api/payments')
.subscribe(payments => this.paymentsSubject.next(payments));
}
}

In this example, toObservable(this.selectedPaymentId) creates an observable from the signal, which can then be used with switchMap to fetch data based on the selected ID. The combination provides a reactive stream that automatically fetches new data whenever the signal changes.


NgRx Store (Enterprise)

NgRx is Angular's implementation of the Redux pattern - a predictable state container based on unidirectional data flow. State is stored in a single immutable tree, and the only way to change state is by dispatching actions. Reducers handle actions and produce new state, while effects handle side effects like HTTP requests.

NgRx provides excellent developer tooling (Redux DevTools), enforces consistency across large teams, and makes state changes traceable through action logs. However, it comes with significant boilerplate and a learning curve. Most applications don't need NgRx - services with RxJS or signals suffice.

When to Use NgRx

Use NgRx when:

  • Multiple features share complex state with intricate interdependencies
  • State changes come from many sources (user actions, WebSockets, background sync)
  • Time-travel debugging is valuable for troubleshooting complex workflows
  • Audit trail required for compliance (every state change is logged as an action)
  • Team is experienced with Redux patterns and can maintain the boilerplate
  • You need strict unidirectional data flow to prevent state inconsistencies

Don't use NgRx for:

  • Simple CRUD applications with straightforward data fetching
  • Small teams unfamiliar with Redux (the learning curve slows development)
  • Mostly server-driven state where the backend is the source of truth
  • Prototypes or MVPs where speed of development is critical

NgRx Setup

NgRx state is defined as TypeScript interfaces, making it type-safe. The state shape should be normalized (avoid deeply nested structures) and include loading/error states for async operations. This pattern is sometimes called the "remote data pattern" and ensures your UI can properly handle loading and error states.

// payment.state.ts
export interface PaymentState {
payments: Payment[]; // Normalized array of payments
selectedPaymentId: string | null; // Currently selected payment ID
loading: boolean; // Tracks async operation state
error: string | null; // Error message if operation fails
}

export const initialState: PaymentState = {
payments: [],
selectedPaymentId: null,
loading: false,
error: null
};

Actions

Actions are plain objects that describe something that happened in the application. They're the only way to trigger state changes in NgRx. Well-named actions make the action log read like a story of what happened in your application, which is invaluable for debugging.

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

export const loadPayments = createAction('[Payment] Load Payments');

export const loadPaymentsSuccess = createAction(
'[Payment] Load Payments Success',
props<{ payments: Payment[] }>()
);

export const loadPaymentsFailure = createAction(
'[Payment] Load Payments Failure',
props<{ error: string }>()
);

export const selectPayment = createAction(
'[Payment] Select Payment',
props<{ id: string }>()
);

Action naming follows the pattern [Source] Event. This makes it clear where the action originated ([Payment] component, service, or effect) and what happened (Load Payments, Select Payment). The props function provides type-safe payload data.

Reducer

Reducers are pure functions that take the current state and an action, and return a new state. They must never mutate the existing state - always return a new object. This immutability is what allows NgRx to efficiently detect changes and enables time-travel debugging.

// payment.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as PaymentActions from './payment.actions';

export const paymentReducer = createReducer(
initialState,

on(PaymentActions.loadPayments, state => ({
...state,
loading: true,
error: null
})),

on(PaymentActions.loadPaymentsSuccess, (state, { payments }) => ({
...state,
payments,
loading: false
})),

on(PaymentActions.loadPaymentsFailure, (state, { error }) => ({
...state,
loading: false,
error
})),

on(PaymentActions.selectPayment, (state, { id }) => ({
...state,
selectedPaymentId: id
}))
);

The on() function listens for specific actions and returns a new state. The spread operator (...state) copies the existing state, then we override specific properties. Note how we handle the full async lifecycle: set loading: true when starting, set loading: false and update data on success, and set error on failure.

Effects

Effects handle side effects - operations that interact with the outside world like HTTP requests, localStorage, routing, or logging. They listen for actions, perform async operations, and dispatch new actions with the results. This keeps reducers pure and testable.

// payment.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';
import * as PaymentActions from './payment.actions';

@Injectable()
export class PaymentEffects {
private actions$ = inject(Actions);
private paymentService = inject(PaymentService);

loadPayments$ = createEffect(() =>
this.actions$.pipe(
ofType(PaymentActions.loadPayments),
switchMap(() =>
this.paymentService.getPayments().pipe(
map(payments => PaymentActions.loadPaymentsSuccess({ payments })),
catchError(error =>
of(PaymentActions.loadPaymentsFailure({ error: error.message }))
)
)
)
)
);
}

Effects use RxJS to create action streams. The ofType() operator filters the action stream for specific action types. switchMap cancels previous HTTP requests if a new action arrives, maps the response to a success action, and handles errors by dispatching failure actions. Effects automatically dispatch the resulting actions back into the store.

Selectors

Selectors are functions that extract specific pieces of state from the store. They're memoized - NgRx caches results and only recomputes when input state changes. This prevents unnecessary recalculations and component updates, improving performance significantly in large state trees.

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

export const selectPaymentState = createFeatureSelector<PaymentState>('payments');

export const selectAllPayments = createSelector(
selectPaymentState,
state => state.payments
);

export const selectSelectedPaymentId = createSelector(
selectPaymentState,
state => state.selectedPaymentId
);

export const selectSelectedPayment = createSelector(
selectAllPayments,
selectSelectedPaymentId,
(payments, selectedId) =>
payments.find(p => p.id === selectedId) ?? null
);

export const selectPaymentsLoading = createSelector(
selectPaymentState,
state => state.loading
);

Using NgRx in Components

@Component({
selector: 'app-payment-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (loading$ | async) {
<div>Loading...</div>
}
@if (payments$ | async; as payments) {
@for (payment of payments; track payment.id) {
<app-payment-card
[payment]="payment"
(click)="selectPayment(payment.id)"
/>
}
}
`
})
export class PaymentListComponent {
private store = inject(Store);

payments$ = this.store.select(selectAllPayments);
loading$ = this.store.select(selectPaymentsLoading);

ngOnInit(): void {
this.store.dispatch(loadPayments());
}

selectPayment(id: string): void {
this.store.dispatch(selectPayment({ id }));
}
}

Component State

Local State with Signals

@Component({
selector: 'app-payment-form',
standalone: true,
template: `
<form (ngSubmit)="onSubmit()">
<input
type="number"
[value]="amount()"
(input)="amount.set($event.target.value)"
/>

<button type="submit" [disabled]="!isValid()">
Submit
</button>

@if (isSubmitting()) {
<span>Processing...</span>
}
</form>
`
})
export class PaymentFormComponent {
// Local component state
amount = signal(0);
isSubmitting = signal(false);

isValid = computed(() => this.amount() > 0);

async onSubmit(): Promise<void> {
this.isSubmitting.set(true);
try {
// Submit logic
await this.submitPayment();
} finally {
this.isSubmitting.set(false);
}
}
}

State Management Patterns Comparison

PatternUse CaseProsCons
Component StateUI-only stateSimple, no overheadNot shareable
Service + RxJSMost shared stateFlexible, familiarManual management
Service + SignalsSync reactive stateSimple, performantLimited to sync
NgRxComplex enterprise appsPredictable, debuggableBoilerplate heavy

Further Reading

Angular Framework Guidelines

Cross-Cutting Guidelines

External Resources


Summary

Key Takeaways

  1. Services with RxJS for most shared state needs in banking apps
  2. Signals for simple reactive state - synchronous, local updates
  3. Combine signals and RxJS using toObservable and toSignal
  4. NgRx for complex enterprise scenarios with audit requirements
  5. Component state for UI-only - modals, dropdowns, form state
  6. BehaviorSubject for shared state - maintains current value
  7. Computed signals derive state without manual updates
  8. Async pipe prevents leaks - use over manual subscriptions
  9. Start simple, add complexity when needed (Services → Signals → NgRx)
  10. Don't over-engineer - most apps don't need NgRx

Next Steps: Review Angular Testing for testing state management patterns and Angular Performance for optimization strategies.