Angular State Management Comprehensive guide to modern Angular state management patterns, from Signal-based local state to global stores and server state synchronization. When to Use This Skill Setting up global state management in Angular Choosing between Signals, NgRx, or Akita Managing component-level stores Implementing optimistic updates Debugging state-related issues Migrating from legacy state patterns Do Not Use This Skill When The task is unrelated to Angular state management You need React state management → use react-state-management Core Concepts State Categories Type Description Solutions Local State Component-specific, UI state Signals, signal() Shared State Between related components Signal services Global State App-wide, complex NgRx, Akita, Elf Server State Remote data, caching NgRx Query, RxAngular URL State Route parameters ActivatedRoute Form State Input values, validation Reactive Forms Selection Criteria Small app, simple state → Signal Services Medium app, moderate state → Component Stores Large app, complex state → NgRx Store Heavy server interaction → NgRx Query + Signal Services Real-time updates → RxAngular + Signals Quick Start: Signal-Based State Pattern 1: Simple Signal Service // services/counter.service.ts import { Injectable , signal , computed } from "@angular/core" ; @ Injectable ( { providedIn : "root" } ) export class CounterService { // Private writable signals private _count = signal ( 0 ) ; // Public read-only readonly count = this . _count . asReadonly ( ) ; readonly doubled = computed ( ( ) => this . _count ( ) * 2 ) ; readonly isPositive = computed ( ( ) => this . _count ( )
0 ) ; increment ( ) { this . _count . update ( ( v ) => v + 1 ) ; } decrement ( ) { this . _count . update ( ( v ) => v - 1 ) ; } reset ( ) { this . _count . set ( 0 ) ; } } // Usage in component @ Component ( { template : `
Count: {{ counter.count() }}
Doubled: {{ counter.doubled() }}
` , } ) export class CounterComponent { counter = inject ( CounterService ) ; } Pattern 2: Feature Signal Store // stores/user.store.ts import { Injectable , signal , computed , inject } from "@angular/core" ; import { HttpClient } from "@angular/common/http" ; import { toSignal } from "@angular/core/rxjs-interop" ; interface User { id : string ; name : string ; email : string ; } interface UserState { user : User | null ; loading : boolean ; error : string | null ; } @ Injectable ( { providedIn : "root" } ) export class UserStore { private http = inject ( HttpClient ) ; // State signals private _user = signal < User | null
( null ) ; private _loading = signal ( false ) ; private _error = signal < string | null
( null ) ; // Selectors (read-only computed) readonly user = computed ( ( ) => this . _user ( ) ) ; readonly loading = computed ( ( ) => this . _loading ( ) ) ; readonly error = computed ( ( ) => this . _error ( ) ) ; readonly isAuthenticated = computed ( ( ) => this . _user ( ) !== null ) ; readonly displayName = computed ( ( ) => this . _user ( ) ?. name ?? "Guest" ) ; // Actions async loadUser ( id : string ) { this . _loading . set ( true ) ; this . _error . set ( null ) ; try { const user = await fetch (
/api/users/ ${ id }) . then ( ( r ) => r . json ( ) ) ; this . _user . set ( user ) ; } catch ( e ) { this . _error . set ( "Failed to load user" ) ; } finally { this . _loading . set ( false ) ; } } updateUser ( updates : Partial < User) { this . _user . update ( ( user ) => ( user ? { ... user , ... updates } : null ) ) ; } logout ( ) { this . _user . set ( null ) ; this . _error . set ( null ) ; } } Pattern 3: SignalStore (NgRx Signals) // stores/products.store.ts import { signalStore , withState , withMethods , withComputed , patchState , } from "@ngrx/signals" ; import { inject } from "@angular/core" ; import { ProductService } from "./product.service" ; interface ProductState { products : Product [ ] ; loading : boolean ; filter : string ; } const initialState : ProductState = { products : [ ] , loading : false , filter : "" , } ; export const ProductStore = signalStore ( { providedIn : "root" } , withState ( initialState ) , withComputed ( ( store ) => ( { filteredProducts : computed ( ( ) => { const filter = store . filter ( ) . toLowerCase ( ) ; return store . products ( ) . filter ( ( p ) => p . name . toLowerCase ( ) . includes ( filter ) ) ; } ) , totalCount : computed ( ( ) => store . products ( ) . length ) , } ) ) , withMethods ( ( store , productService = inject ( ProductService ) ) => ( { async loadProducts ( ) { patchState ( store , { loading : true } ) ; try { const products = await productService . getAll ( ) ; patchState ( store , { products , loading : false } ) ; } catch { patchState ( store , { loading : false } ) ; } } , setFilter ( filter : string ) { patchState ( store , { filter } ) ; } , addProduct ( product : Product ) { patchState ( store , ( { products } ) => ( { products : [ ... products , product ] , } ) ) ; } , } ) ) , ) ; // Usage @ Component ( { template :
<input (input)="store.setFilter($event.target.value)" /> @if (store.loading()) { <app-spinner /> } @else { @for (product of store.filteredProducts(); track product.id) { <app-product-card [product]="product" /> } }, } ) export class ProductListComponent { store = inject ( ProductStore ) ; ngOnInit ( ) { this . store . loadProducts ( ) ; } } NgRx Store (Global State) Setup // store/app.state.ts import { ActionReducerMap } from "@ngrx/store" ; export interface AppState { user : UserState ; cart : CartState ; } export const reducers : ActionReducerMap < AppState= { user : userReducer , cart : cartReducer , } ; // main.ts bootstrapApplication ( AppComponent , { providers : [ provideStore ( reducers ) , provideEffects ( [ UserEffects , CartEffects ] ) , provideStoreDevtools ( { maxAge : 25 } ) , ] , } ) ; Feature Slice Pattern // store/user/user.actions.ts import { createActionGroup , props , emptyProps } from "@ngrx/store" ; export const UserActions = createActionGroup ( { source : "User" , events : { "Load User" : props < { userId : string }
( ) , "Load User Success" : props < { user : User }
( ) , "Load User Failure" : props < { error : string }
( ) , "Update User" : props < { updates : Partial < User
}
( ) , Logout : emptyProps ( ) , } , } ) ; // store/user/user.reducer.ts import { createReducer , on } from "@ngrx/store" ; import { UserActions } from "./user.actions" ; export interface UserState { user : User | null ; loading : boolean ; error : string | null ; } const initialState : UserState = { user : null , loading : false , error : null , } ; export const userReducer = createReducer ( initialState , on ( UserActions . loadUser , ( state ) => ( { ... state , loading : true , error : null , } ) ) , on ( UserActions . loadUserSuccess , ( state , { user } ) => ( { ... state , user , loading : false , } ) ) , on ( UserActions . loadUserFailure , ( state , { error } ) => ( { ... state , loading : false , error , } ) ) , on ( UserActions . logout , ( ) => initialState ) , ) ; // store/user/user.selectors.ts import { createFeatureSelector , createSelector } from "@ngrx/store" ; import { UserState } from "./user.reducer" ; export const selectUserState = createFeatureSelector < UserState
( "user" ) ; export const selectUser = createSelector ( selectUserState , ( state ) => state . user , ) ; export const selectUserLoading = createSelector ( selectUserState , ( state ) => state . loading , ) ; export const selectIsAuthenticated = createSelector ( selectUser , ( user ) => user !== null , ) ; // store/user/user.effects.ts import { Injectable , inject } from "@angular/core" ; import { Actions , createEffect , ofType } from "@ngrx/effects" ; import { switchMap , map , catchError , of } from "rxjs" ; @ Injectable ( ) export class UserEffects { private actions$ = inject ( Actions ) ; private userService = inject ( UserService ) ; loadUser$ = createEffect ( ( ) => this . actions$ . pipe ( ofType ( UserActions . loadUser ) , switchMap ( ( { userId } ) => this . userService . getUser ( userId ) . pipe ( map ( ( user ) => UserActions . loadUserSuccess ( { user } ) ) , catchError ( ( error ) => of ( UserActions . loadUserFailure ( { error : error . message } ) ) , ) , ) , ) , ) , ) ; } Component Usage @ Component ( { template : ` @if (loading()) {
} @else if (user(); as user) {
Welcome, {{ user.name }}
} ` , } ) export class HeaderComponent { private store = inject ( Store ) ; user = this . store . selectSignal ( selectUser ) ; loading = this . store . selectSignal ( selectUserLoading ) ; logout ( ) { this . store . dispatch ( UserActions . logout ( ) ) ; } } RxJS-Based Patterns Component Store (Local Feature State) // stores/todo.store.ts import { Injectable } from "@angular/core" ; import { ComponentStore } from "@ngrx/component-store" ; import { switchMap , tap , catchError , EMPTY } from "rxjs" ; interface TodoState { todos : Todo [ ] ; loading : boolean ; } @ Injectable ( ) export class TodoStore extends ComponentStore < TodoState
{ constructor ( private todoService : TodoService ) { super ( { todos : [ ] , loading : false } ) ; } // Selectors readonly todos$ = this . select ( ( state ) => state . todos ) ; readonly loading$ = this . select ( ( state ) => state . loading ) ; readonly completedCount$ = this . select ( this . todos$ , ( todos ) => todos . filter ( ( t ) => t . completed ) . length , ) ; // Updaters readonly addTodo = this . updater ( ( state , todo : Todo ) => ( { ... state , todos : [ ... state . todos , todo ] , } ) ) ; readonly toggleTodo = this . updater ( ( state , id : string ) => ( { ... state , todos : state . todos . map ( ( t ) => t . id === id ? { ... t , completed : ! t . completed } : t , ) , } ) ) ; // Effects readonly loadTodos = this . effect < void
( ( trigger$ ) => trigger$ . pipe ( tap ( ( ) => this . patchState ( { loading : true } ) ) , switchMap ( ( ) => this . todoService . getAll ( ) . pipe ( tap ( { next : ( todos ) => this . patchState ( { todos , loading : false } ) , error : ( ) => this . patchState ( { loading : false } ) , } ) , catchError ( ( ) => EMPTY ) , ) , ) , ) , ) ; } Server State with Signals HTTP + Signals Pattern // services/api.service.ts import { Injectable , signal , inject } from "@angular/core" ; import { HttpClient } from "@angular/common/http" ; import { toSignal } from "@angular/core/rxjs-interop" ; interface ApiState < T
{ data : T | null ; loading : boolean ; error : string | null ; } @ Injectable ( { providedIn : "root" } ) export class ProductApiService { private http = inject ( HttpClient ) ; private _state = signal < ApiState < Product [ ]
( { data : null , loading : false , error : null , } ) ; readonly products = computed ( ( ) => this . _state ( ) . data ?? [ ] ) ; readonly loading = computed ( ( ) => this . _state ( ) . loading ) ; readonly error = computed ( ( ) => this . _state ( ) . error ) ; async fetchProducts ( ) : Promise < void
{ this . _state . update ( ( s ) => ( { ... s , loading : true , error : null } ) ) ; try { const data = await firstValueFrom ( this . http . get < Product [ ]
( "/api/products" ) , ) ; this . _state . update ( ( s ) => ( { ... s , data , loading : false } ) ) ; } catch ( e ) { this . _state . update ( ( s ) => ( { ... s , loading : false , error : "Failed to fetch products" , } ) ) ; } } // Optimistic update async deleteProduct ( id : string ) : Promise < void
{ const previousData = this . _state ( ) . data ; // Optimistically remove this . _state . update ( ( s ) => ( { ... s , data : s . data ?. filter ( ( p ) => p . id !== id ) ?? null , } ) ) ; try { await firstValueFrom ( this . http . delete (
/api/products/ ${ id }) ) ; } catch { // Rollback on error this . _state . update ( ( s ) => ( { ... s , data : previousData } ) ) ; } } } Best Practices Do's Practice Why Use Signals for local state Simple, reactive, no subscriptions Use computed() for derived data Auto-updates, memoized Colocate state with feature Easier to maintain Use NgRx for complex flows Actions, effects, devtools Prefer inject() over constructor Cleaner, works in factories Don'ts Anti-Pattern Instead Store derived data Use computed() Mutate signals directly Use set() or update() Over-globalize state Keep local when possible Mix RxJS and Signals chaotically Choose primary, bridge with toSignal / toObservable Subscribe in components for state Use template with signals Migration Path From BehaviorSubject to Signals // Before: RxJS-based @ Injectable ( { providedIn : "root" } ) export class OldUserService { private userSubject = new BehaviorSubject < User | null( null ) ; user$ = this . userSubject . asObservable ( ) ; setUser ( user : User ) { this . userSubject . next ( user ) ; } } // After: Signal-based @ Injectable ( { providedIn : "root" } ) export class UserService { private _user = signal < User | null
( null ) ; readonly user = this . _user . asReadonly ( ) ; setUser ( user : User ) { this . _user . set ( user ) ; } } Bridging Signals and RxJS import { toSignal , toObservable } from '@angular/core/rxjs-interop' ; // Observable → Signal @ Component ( { ... } ) export class ExampleComponent { private route = inject ( ActivatedRoute ) ; // Convert Observable to Signal userId = toSignal ( this . route . params . pipe ( map ( p => p [ 'id' ] ) ) , { initialValue : '' } ) ; } // Signal → Observable export class DataService { private filter = signal ( '' ) ; // Convert Signal to Observable filter$ = toObservable ( this . filter ) ; filteredData$ = this . filter$ . pipe ( debounceTime ( 300 ) , switchMap ( filter => this . http . get (
/api/data?q= ${ filter }) ) ) ; } Resources Angular Signals Guide NgRx Documentation NgRx SignalStore RxAngular