Master modern Angular state management with Signals, NgRx, and RxJS. Use when setting up global state, managing component stores, choosing between state solutions, or migrating from legacy patterns.
Add this skill
npx mdskills install sickn33/angular-state-managementComprehensive state management guide with clear decision frameworks and modern Angular patterns
1---2name: angular-state-management3description: Master modern Angular state management with Signals, NgRx, and RxJS. Use when setting up global state, managing component stores, choosing between state solutions, or migrating from legacy patterns.4risk: safe5source: self6---78# Angular State Management910Comprehensive guide to modern Angular state management patterns, from Signal-based local state to global stores and server state synchronization.1112## When to Use This Skill1314- Setting up global state management in Angular15- Choosing between Signals, NgRx, or Akita16- Managing component-level stores17- Implementing optimistic updates18- Debugging state-related issues19- Migrating from legacy state patterns2021## Do Not Use This Skill When2223- The task is unrelated to Angular state management24- You need React state management → use `react-state-management`2526---2728## Core Concepts2930### State Categories3132| Type | Description | Solutions |33| ---------------- | ---------------------------- | --------------------- |34| **Local State** | Component-specific, UI state | Signals, `signal()` |35| **Shared State** | Between related components | Signal services |36| **Global State** | App-wide, complex | NgRx, Akita, Elf |37| **Server State** | Remote data, caching | NgRx Query, RxAngular |38| **URL State** | Route parameters | ActivatedRoute |39| **Form State** | Input values, validation | Reactive Forms |4041### Selection Criteria4243```44Small app, simple state → Signal Services45Medium app, moderate state → Component Stores46Large app, complex state → NgRx Store47Heavy server interaction → NgRx Query + Signal Services48Real-time updates → RxAngular + Signals49```5051---5253## Quick Start: Signal-Based State5455### Pattern 1: Simple Signal Service5657```typescript58// services/counter.service.ts59import { Injectable, signal, computed } from "@angular/core";6061@Injectable({ providedIn: "root" })62export class CounterService {63 // Private writable signals64 private _count = signal(0);6566 // Public read-only67 readonly count = this._count.asReadonly();68 readonly doubled = computed(() => this._count() * 2);69 readonly isPositive = computed(() => this._count() > 0);7071 increment() {72 this._count.update((v) => v + 1);73 }7475 decrement() {76 this._count.update((v) => v - 1);77 }7879 reset() {80 this._count.set(0);81 }82}8384// Usage in component85@Component({86 template: `87 <p>Count: {{ counter.count() }}</p>88 <p>Doubled: {{ counter.doubled() }}</p>89 <button (click)="counter.increment()">+</button>90 `,91})92export class CounterComponent {93 counter = inject(CounterService);94}95```9697### Pattern 2: Feature Signal Store9899```typescript100// stores/user.store.ts101import { Injectable, signal, computed, inject } from "@angular/core";102import { HttpClient } from "@angular/common/http";103import { toSignal } from "@angular/core/rxjs-interop";104105interface User {106 id: string;107 name: string;108 email: string;109}110111interface UserState {112 user: User | null;113 loading: boolean;114 error: string | null;115}116117@Injectable({ providedIn: "root" })118export class UserStore {119 private http = inject(HttpClient);120121 // State signals122 private _user = signal<User | null>(null);123 private _loading = signal(false);124 private _error = signal<string | null>(null);125126 // Selectors (read-only computed)127 readonly user = computed(() => this._user());128 readonly loading = computed(() => this._loading());129 readonly error = computed(() => this._error());130 readonly isAuthenticated = computed(() => this._user() !== null);131 readonly displayName = computed(() => this._user()?.name ?? "Guest");132133 // Actions134 async loadUser(id: string) {135 this._loading.set(true);136 this._error.set(null);137138 try {139 const user = await fetch(`/api/users/${id}`).then((r) => r.json());140 this._user.set(user);141 } catch (e) {142 this._error.set("Failed to load user");143 } finally {144 this._loading.set(false);145 }146 }147148 updateUser(updates: Partial<User>) {149 this._user.update((user) => (user ? { ...user, ...updates } : null));150 }151152 logout() {153 this._user.set(null);154 this._error.set(null);155 }156}157```158159### Pattern 3: SignalStore (NgRx Signals)160161```typescript162// stores/products.store.ts163import {164 signalStore,165 withState,166 withMethods,167 withComputed,168 patchState,169} from "@ngrx/signals";170import { inject } from "@angular/core";171import { ProductService } from "./product.service";172173interface ProductState {174 products: Product[];175 loading: boolean;176 filter: string;177}178179const initialState: ProductState = {180 products: [],181 loading: false,182 filter: "",183};184185export const ProductStore = signalStore(186 { providedIn: "root" },187188 withState(initialState),189190 withComputed((store) => ({191 filteredProducts: computed(() => {192 const filter = store.filter().toLowerCase();193 return store194 .products()195 .filter((p) => p.name.toLowerCase().includes(filter));196 }),197 totalCount: computed(() => store.products().length),198 })),199200 withMethods((store, productService = inject(ProductService)) => ({201 async loadProducts() {202 patchState(store, { loading: true });203204 try {205 const products = await productService.getAll();206 patchState(store, { products, loading: false });207 } catch {208 patchState(store, { loading: false });209 }210 },211212 setFilter(filter: string) {213 patchState(store, { filter });214 },215216 addProduct(product: Product) {217 patchState(store, ({ products }) => ({218 products: [...products, product],219 }));220 },221 })),222);223224// Usage225@Component({226 template: `227 <input (input)="store.setFilter($event.target.value)" />228 @if (store.loading()) {229 <app-spinner />230 } @else {231 @for (product of store.filteredProducts(); track product.id) {232 <app-product-card [product]="product" />233 }234 }235 `,236})237export class ProductListComponent {238 store = inject(ProductStore);239240 ngOnInit() {241 this.store.loadProducts();242 }243}244```245246---247248## NgRx Store (Global State)249250### Setup251252```typescript253// store/app.state.ts254import { ActionReducerMap } from "@ngrx/store";255256export interface AppState {257 user: UserState;258 cart: CartState;259}260261export const reducers: ActionReducerMap<AppState> = {262 user: userReducer,263 cart: cartReducer,264};265266// main.ts267bootstrapApplication(AppComponent, {268 providers: [269 provideStore(reducers),270 provideEffects([UserEffects, CartEffects]),271 provideStoreDevtools({ maxAge: 25 }),272 ],273});274```275276### Feature Slice Pattern277278```typescript279// store/user/user.actions.ts280import { createActionGroup, props, emptyProps } from "@ngrx/store";281282export const UserActions = createActionGroup({283 source: "User",284 events: {285 "Load User": props<{ userId: string }>(),286 "Load User Success": props<{ user: User }>(),287 "Load User Failure": props<{ error: string }>(),288 "Update User": props<{ updates: Partial<User> }>(),289 Logout: emptyProps(),290 },291});292```293294```typescript295// store/user/user.reducer.ts296import { createReducer, on } from "@ngrx/store";297import { UserActions } from "./user.actions";298299export interface UserState {300 user: User | null;301 loading: boolean;302 error: string | null;303}304305const initialState: UserState = {306 user: null,307 loading: false,308 error: null,309};310311export const userReducer = createReducer(312 initialState,313314 on(UserActions.loadUser, (state) => ({315 ...state,316 loading: true,317 error: null,318 })),319320 on(UserActions.loadUserSuccess, (state, { user }) => ({321 ...state,322 user,323 loading: false,324 })),325326 on(UserActions.loadUserFailure, (state, { error }) => ({327 ...state,328 loading: false,329 error,330 })),331332 on(UserActions.logout, () => initialState),333);334```335336```typescript337// store/user/user.selectors.ts338import { createFeatureSelector, createSelector } from "@ngrx/store";339import { UserState } from "./user.reducer";340341export const selectUserState = createFeatureSelector<UserState>("user");342343export const selectUser = createSelector(344 selectUserState,345 (state) => state.user,346);347348export const selectUserLoading = createSelector(349 selectUserState,350 (state) => state.loading,351);352353export const selectIsAuthenticated = createSelector(354 selectUser,355 (user) => user !== null,356);357```358359```typescript360// store/user/user.effects.ts361import { Injectable, inject } from "@angular/core";362import { Actions, createEffect, ofType } from "@ngrx/effects";363import { switchMap, map, catchError, of } from "rxjs";364365@Injectable()366export class UserEffects {367 private actions$ = inject(Actions);368 private userService = inject(UserService);369370 loadUser$ = createEffect(() =>371 this.actions$.pipe(372 ofType(UserActions.loadUser),373 switchMap(({ userId }) =>374 this.userService.getUser(userId).pipe(375 map((user) => UserActions.loadUserSuccess({ user })),376 catchError((error) =>377 of(UserActions.loadUserFailure({ error: error.message })),378 ),379 ),380 ),381 ),382 );383}384```385386### Component Usage387388```typescript389@Component({390 template: `391 @if (loading()) {392 <app-spinner />393 } @else if (user(); as user) {394 <h1>Welcome, {{ user.name }}</h1>395 <button (click)="logout()">Logout</button>396 }397 `,398})399export class HeaderComponent {400 private store = inject(Store);401402 user = this.store.selectSignal(selectUser);403 loading = this.store.selectSignal(selectUserLoading);404405 logout() {406 this.store.dispatch(UserActions.logout());407 }408}409```410411---412413## RxJS-Based Patterns414415### Component Store (Local Feature State)416417```typescript418// stores/todo.store.ts419import { Injectable } from "@angular/core";420import { ComponentStore } from "@ngrx/component-store";421import { switchMap, tap, catchError, EMPTY } from "rxjs";422423interface TodoState {424 todos: Todo[];425 loading: boolean;426}427428@Injectable()429export class TodoStore extends ComponentStore<TodoState> {430 constructor(private todoService: TodoService) {431 super({ todos: [], loading: false });432 }433434 // Selectors435 readonly todos$ = this.select((state) => state.todos);436 readonly loading$ = this.select((state) => state.loading);437 readonly completedCount$ = this.select(438 this.todos$,439 (todos) => todos.filter((t) => t.completed).length,440 );441442 // Updaters443 readonly addTodo = this.updater((state, todo: Todo) => ({444 ...state,445 todos: [...state.todos, todo],446 }));447448 readonly toggleTodo = this.updater((state, id: string) => ({449 ...state,450 todos: state.todos.map((t) =>451 t.id === id ? { ...t, completed: !t.completed } : t,452 ),453 }));454455 // Effects456 readonly loadTodos = this.effect<void>((trigger$) =>457 trigger$.pipe(458 tap(() => this.patchState({ loading: true })),459 switchMap(() =>460 this.todoService.getAll().pipe(461 tap({462 next: (todos) => this.patchState({ todos, loading: false }),463 error: () => this.patchState({ loading: false }),464 }),465 catchError(() => EMPTY),466 ),467 ),468 ),469 );470}471```472473---474475## Server State with Signals476477### HTTP + Signals Pattern478479```typescript480// services/api.service.ts481import { Injectable, signal, inject } from "@angular/core";482import { HttpClient } from "@angular/common/http";483import { toSignal } from "@angular/core/rxjs-interop";484485interface ApiState<T> {486 data: T | null;487 loading: boolean;488 error: string | null;489}490491@Injectable({ providedIn: "root" })492export class ProductApiService {493 private http = inject(HttpClient);494495 private _state = signal<ApiState<Product[]>>({496 data: null,497 loading: false,498 error: null,499 });500501 readonly products = computed(() => this._state().data ?? []);502 readonly loading = computed(() => this._state().loading);503 readonly error = computed(() => this._state().error);504505 async fetchProducts(): Promise<void> {506 this._state.update((s) => ({ ...s, loading: true, error: null }));507508 try {509 const data = await firstValueFrom(510 this.http.get<Product[]>("/api/products"),511 );512 this._state.update((s) => ({ ...s, data, loading: false }));513 } catch (e) {514 this._state.update((s) => ({515 ...s,516 loading: false,517 error: "Failed to fetch products",518 }));519 }520 }521522 // Optimistic update523 async deleteProduct(id: string): Promise<void> {524 const previousData = this._state().data;525526 // Optimistically remove527 this._state.update((s) => ({528 ...s,529 data: s.data?.filter((p) => p.id !== id) ?? null,530 }));531532 try {533 await firstValueFrom(this.http.delete(`/api/products/${id}`));534 } catch {535 // Rollback on error536 this._state.update((s) => ({ ...s, data: previousData }));537 }538 }539}540```541542---543544## Best Practices545546### Do's547548| Practice | Why |549| ---------------------------------- | ---------------------------------- |550| Use Signals for local state | Simple, reactive, no subscriptions |551| Use `computed()` for derived data | Auto-updates, memoized |552| Colocate state with feature | Easier to maintain |553| Use NgRx for complex flows | Actions, effects, devtools |554| Prefer `inject()` over constructor | Cleaner, works in factories |555556### Don'ts557558| Anti-Pattern | Instead |559| --------------------------------- | ----------------------------------------------------- |560| Store derived data | Use `computed()` |561| Mutate signals directly | Use `set()` or `update()` |562| Over-globalize state | Keep local when possible |563| Mix RxJS and Signals chaotically | Choose primary, bridge with `toSignal`/`toObservable` |564| Subscribe in components for state | Use template with signals |565566---567568## Migration Path569570### From BehaviorSubject to Signals571572```typescript573// Before: RxJS-based574@Injectable({ providedIn: "root" })575export class OldUserService {576 private userSubject = new BehaviorSubject<User | null>(null);577 user$ = this.userSubject.asObservable();578579 setUser(user: User) {580 this.userSubject.next(user);581 }582}583584// After: Signal-based585@Injectable({ providedIn: "root" })586export class UserService {587 private _user = signal<User | null>(null);588 readonly user = this._user.asReadonly();589590 setUser(user: User) {591 this._user.set(user);592 }593}594```595596### Bridging Signals and RxJS597598```typescript599import { toSignal, toObservable } from '@angular/core/rxjs-interop';600601// Observable → Signal602@Component({...})603export class ExampleComponent {604 private route = inject(ActivatedRoute);605606 // Convert Observable to Signal607 userId = toSignal(608 this.route.params.pipe(map(p => p['id'])),609 { initialValue: '' }610 );611}612613// Signal → Observable614export class DataService {615 private filter = signal('');616617 // Convert Signal to Observable618 filter$ = toObservable(this.filter);619620 filteredData$ = this.filter$.pipe(621 debounceTime(300),622 switchMap(filter => this.http.get(`/api/data?q=${filter}`))623 );624}625```626627---628629## Resources630631- [Angular Signals Guide](https://angular.dev/guide/signals)632- [NgRx Documentation](https://ngrx.io/)633- [NgRx SignalStore](https://ngrx.io/guide/signals)634- [RxAngular](https://www.rx-angular.io/)635
Full transparency — inspect the skill content before installing.