state-management

State Management Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "state-management" with this command: npx skills add tidemann/st44-home/tidemann-st44-home-state-management

State Management Skill

Expert in Angular 21+ state management using signals, NgRx SignalStore, resource APIs, and modern reactive patterns.

When to Use This Skill

Use this skill when:

  • Implementing shared state across multiple components

  • Setting up centralized state store (service-based or NgRx SignalStore)

  • Migrating from component-level to centralized state

  • Implementing AsyncState pattern or resource APIs for data loading

  • Choosing between signals, RxJS, or combined approaches

  • Abstracting localStorage access

  • Debugging state synchronization issues

Angular 21 State Management for 2025

Modern Context

Zoneless by Default: Angular v21 uses signals for change detection with zone.js no longer included by default. Zoneless change detection reached stability in v20.2.

Signals-First Development: Signals are now the driving force for modern state management in Angular, providing synchronous reactive state and UI reactivity.

Choose the Right Tool for Your Scope

Small to Medium Apps (1-5 developers):

  • Use Angular Signals for local component state

  • Use service-based stores for shared feature state

  • Consider signal state or pure signals

Large Enterprise Apps:

  • Use NgRx SignalStore for structured approach

  • Break state into logical, feature-based stores (avoid monolithic store)

  • Use composition with feature-specific stores

Signals vs RxJS: When to Use Each

Use Signals for:

  • Synchronous internal state

  • UI reactivity and derived state

  • Component-local state management

  • When you need fine-grained reactivity

Use RxJS Observables for:

  • Asynchronous data streams (HTTP, WebSockets)

  • Complex operator-based data manipulation

  • Event handling and timing operations

  • When you need operators like debounce, throttle, retry

Best Practice: Use both together - Observables for async operations, convert to signals at UI boundary using toSignal() .

Key Design Principles (2025)

  • Immutability: Always treat state as immutable. Use patchState() for updates in SignalStore.

  • Feature-Based Stores: Break down state into logical domains (tasks, users, settings), not one monolithic store.

  • Separation of Concerns: Store holds state and computed values. Service handles data operations (API calls).

  • Type Safety: Use signalState() for enhanced type safety over plain signals.

  • Performance: Computed signals prevent unnecessary recalculations - use them for derived state.

Critical Decision: Shared vs Local State

Shared State (Centralized Store)

Use centralized store when:

  • Data is needed by multiple pages/components

  • Data needs to be cached across navigation

  • State must be synchronized across the app

  • Examples: household, user, tasks, assignments, app settings

Implementation:

// store/app.store.ts import { Injectable, computed, signal } from '@angular/core'; import { Household, Task, Assignment, User } from '@st44/types/schemas';

@Injectable({ providedIn: 'root' }) export class AppStore { // Private writable signals private readonly householdSignal = signal<Household | null>(null); private readonly tasksSignal = signal<Task[]>([]); private readonly assignmentsSignal = signal<Assignment[]>([]); private readonly userSignal = signal<User | null>(null);

// Public readonly access readonly household = this.householdSignal.asReadonly(); readonly tasks = this.tasksSignal.asReadonly(); readonly assignments = this.assignmentsSignal.asReadonly(); readonly user = this.userSignal.asReadonly();

// Computed/derived state readonly activeTasks = computed(() => this.tasksSignal().filter((t) => t.active));

readonly pendingAssignments = computed(() => this.assignmentsSignal().filter((a) => a.status === 'pending'), );

// Actions setHousehold(household: Household | null): void { this.householdSignal.set(household); }

setTasks(tasks: Task[]): void { this.tasksSignal.set(tasks); }

updateTask(taskId: string, updates: Partial<Task>): void { this.tasksSignal.update((tasks) => tasks.map((t) => (t.id === taskId ? { ...t, ...updates } : t)), ); }

addTask(task: Task): void { this.tasksSignal.update((tasks) => [...tasks, task]); }

removeTask(taskId: string): void { this.tasksSignal.update((tasks) => tasks.filter((t) => t.id !== taskId)); }

clear(): void { this.householdSignal.set(null); this.tasksSignal.set([]); this.assignmentsSignal.set([]); this.userSignal.set(null); } }

Using the Store:

import { inject } from '@angular/core'; import { AppStore } from '../store/app.store';

export class MyComponent { private readonly store = inject(AppStore);

// Access state protected readonly household = this.store.household; protected readonly activeTasks = this.store.activeTasks;

// Update state protected addNewTask(task: Task): void { this.store.addTask(task); } }

Local Component State

Use local signals when:

  • State is specific to one component

  • State doesn't need to be shared

  • Examples: UI state (expanded, selected), form inputs, local toggles

export class MyComponent { // Local state protected readonly expanded = signal(false); protected readonly selectedId = signal<string | null>(null);

// Derived state protected readonly hasSelection = computed(() => this.selectedId() !== null);

toggle(): void { this.expanded.update((v) => !v); } }

NgRx SignalStore (Enterprise Pattern)

NgRx SignalStore is recommended for larger applications (5+ developers) needing structured state management.

Core Building Blocks

import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals'; import { inject } from '@angular/core';

export const TaskStore = signalStore( { providedIn: 'root' },

// State withState({ tasks: [] as Task[], loading: false, filter: 'all' as 'all' | 'active' | 'completed', }),

// Computed withComputed(({ tasks, filter }) => ({ filteredTasks: computed(() => { const allTasks = tasks(); const currentFilter = filter();

  if (currentFilter === 'all') return allTasks;
  return allTasks.filter((t) => (currentFilter === 'active' ? t.active : !t.active));
}),

})),

// Methods withMethods((store, taskService = inject(TaskService)) => ({ async loadTasks() { patchState(store, { loading: true }); try { const tasks = await taskService.getTasks(); patchState(store, { tasks, loading: false }); } catch (error) { patchState(store, { loading: false }); } },

// Optimistic update
async updateTask(taskId: string, updates: Partial&#x3C;Task>) {
  const previousTasks = store.tasks();

  // Optimistic update
  patchState(store, {
    tasks: previousTasks.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
  });

  try {
    await taskService.updateTask(taskId, updates);
  } catch (error) {
    // Rollback on error
    patchState(store, { tasks: previousTasks });
    throw error;
  }
},

})), );

When to Use SignalStore vs Service Store

Use Service-Based Store when:

  • Simple to mid-size project (1-5 developers)

  • Straightforward state needs

  • Prefer lighter weight solution

Use NgRx SignalStore when:

  • Large enterprise app (5+ developers)

  • Need structured patterns and composition

  • Want feature extraction and reusability

  • Team familiar with NgRx patterns

SignalStore Best Practices

  • Separation of Concerns: Store holds reactive state, Service handles data operations

  • Feature Composition: Extract reusable features that can be composed

  • Optimistic Updates: Update UI immediately, rollback on error

  • Type Safety: Let TypeScript inference handle types (minimal explicit typing needed)

Angular Resource APIs (Modern Data Fetching)

Angular 19.2+ provides specialized APIs for declarative async data loading.

httpResource (Recommended for HTTP)

import { httpResource } from '@angular/core'; import { inject } from '@angular/core';

export class TaskListComponent { private http = inject(HttpClient);

// Reactive data fetching with signals protected tasksResource = httpResource({ url: () => /api/tasks, loader: ({ abortSignal }) => this.http.get<Task[]>('/api/tasks', { context: new HttpContext().set(ABORT_SIGNAL, abortSignal), }), });

// Access as signals protected tasks = this.tasksResource.value; protected isLoading = this.tasksResource.isLoading; protected error = this.tasksResource.error;

// Reload data protected reload() { this.tasksResource.reload(); } }

rxResource (For RxJS Integration)

import { rxResource } from '@angular/core/rxjs-interop'; import { inject, signal } from '@angular/core';

export class TaskListComponent { private taskService = inject(TaskService); private filter = signal<'all' | 'active'>('all');

// rxResource for observable-based data protected tasksResource = rxResource({ request: () => ({ filter: this.filter() }), loader: ({ request }) => this.taskService.getTasks(request.filter), });

// Previous data remains visible while loading protected tasks = this.tasksResource.value; protected isLoading = this.tasksResource.isLoading; }

Best Practices for Resource APIs

  • Use httpResource first for HTTP requests - simpler than resource/rxResource

  • Keep previous data visible: Don't collapse UI while loading new data

  • Handle experimental status: APIs are experimental in v20, may change

  • Combine with signals: Use computed() for derived state from resource values

  • AbortSignal support: Always pass abortSignal to prevent memory leaks

AsyncState Utility Pattern

Problem: Every component has repetitive loading/error/data pattern

Solution: Use AsyncState utility class

Implementation

// utils/async-state.ts import { signal, computed } from '@angular/core';

export class AsyncState<T> { readonly state = signal< | { status: 'idle' } | { status: 'loading' } | { status: 'error'; error: string } | { status: 'success'; data: T }

({ status: 'idle' });

// Computed helpers readonly isLoading = computed(() => this.state().status === 'loading'); readonly isSuccess = computed(() => this.state().status === 'success'); readonly isError = computed(() => this.state().status === 'error'); readonly isIdle = computed(() => this.state().status === 'idle');

readonly error = computed(() => (this.state().status === 'error' ? this.state().error : null));

readonly data = computed(() => (this.state().status === 'success' ? this.state().data : null));

async execute(fn: () => Promise<T>): Promise<void> { this.state.set({ status: 'loading' }); try { const data = await fn(); this.state.set({ status: 'success', data }); } catch (error) { this.state.set({ status: 'error', error: error instanceof Error ? error.message : String(error), }); } }

reset(): void { this.state.set({ status: 'idle' }); } }

Usage in Components

import { AsyncState } from '../utils/async-state';

export class MyComponent { private readonly taskService = inject(TaskService);

// Create AsyncState instance protected readonly tasksState = new AsyncState<Task[]>();

// Use computed helpers protected readonly isLoading = this.tasksState.isLoading; protected readonly error = this.tasksState.error; protected readonly tasks = this.tasksState.data;

async ngOnInit(): Promise<void> { await this.loadTasks(); }

async loadTasks(): Promise<void> { await this.tasksState.execute(async () => { return this.taskService.getTasks(); }); } }

Template Usage

@if (isLoading()) { <div>Loading...</div> } @if (error()) { <div class="error">{{ error() }}</div> } @if (tasks()) { @for (task of tasks(); track task.id) { <app-task-card [task]="task" /> } }

Benefits:

  • Eliminates 15+ lines of boilerplate per component

  • Type-safe discriminated unions

  • Consistent error handling

  • Automatic loading state management

  • Easier to test

StorageService Pattern

Problem: 9+ files directly access localStorage without type safety

Solution: Centralized, type-safe StorageService

Implementation

// services/storage.service.ts import { Injectable } from '@angular/core'; import { z } from 'zod';

export const STORAGE_KEYS = { ACTIVE_HOUSEHOLD_ID: 'activeHouseholdId', TASKS_FILTER: 'tasksFilter', USER_PREFERENCES: 'userPreferences', } as const;

@Injectable({ providedIn: 'root', }) export class StorageService { get<T>(key: string, schema: z.ZodType<T>): T | null { const value = localStorage.getItem(key); if (!value) return null;

try {
  const parsed = JSON.parse(value);
  return schema.parse(parsed);
} catch {
  // Invalid data, remove it
  this.remove(key);
  return null;
}

}

set<T>(key: string, value: T): void { localStorage.setItem(key, JSON.stringify(value)); }

setWithTTL<T>(key: string, value: T, ttlMs: number): void { const item = { value, expiry: Date.now() + ttlMs, }; localStorage.setItem(key, JSON.stringify(item)); }

getWithTTL<T>(key: string, schema: z.ZodType<T>): T | null { const value = localStorage.getItem(key); if (!value) return null;

try {
  const parsed = JSON.parse(value);
  if (parsed.expiry &#x26;&#x26; Date.now() > parsed.expiry) {
    this.remove(key);
    return null;
  }
  return schema.parse(parsed.value);
} catch {
  this.remove(key);
  return null;
}

}

remove(key: string): void { localStorage.removeItem(key); }

clear(): void { localStorage.clear(); }

has(key: string): boolean { return localStorage.getItem(key) !== null; } }

Usage

import { inject } from '@angular/core'; import { z } from 'zod'; import { StorageService, STORAGE_KEYS } from '../services/storage.service';

export class MyComponent { private readonly storage = inject(StorageService);

// Define schema private readonly filterSchema = z.object({ status: z.enum(['all', 'pending', 'completed']), sortBy: z.string(), });

// Load with type safety loadFilter(): void { const filter = this.storage.get(STORAGE_KEYS.TASKS_FILTER, this.filterSchema); if (filter) { this.applyFilter(filter); } }

// Save saveFilter(filter: { status: string; sortBy: string }): void { this.storage.set(STORAGE_KEYS.TASKS_FILTER, filter); }

// With TTL (expires after 1 hour) saveTemporaryData(data: unknown): void { this.storage.setWithTTL('tempData', data, 60 * 60 * 1000); } }

Benefits:

  • Type safety with Zod validation

  • Centralized key management

  • TTL support for cached data

  • Automatic cleanup of invalid data

  • Easy to mock for testing

  • No direct localStorage coupling

Migration Guide

Migrating to Centralized Store

Before (component-level state):

// home.component.ts export class Home { private readonly householdService = inject(HouseholdService); protected household = signal<Household | null>(null);

async ngOnInit(): Promise<void> { this.household.set(await this.householdService.getActive()); } }

// tasks.component.ts export class Tasks { private readonly householdService = inject(HouseholdService); protected household = signal<Household | null>(null);

async ngOnInit(): Promise<void> { // DUPLICATE API CALL! this.household.set(await this.householdService.getActive()); } }

After (centralized store):

// app.store.ts @Injectable({ providedIn: 'root' }) export class AppStore { private readonly householdSignal = signal<Household | null>(null); readonly household = this.householdSignal.asReadonly();

setHousehold(household: Household): void { this.householdSignal.set(household); } }

// app initialization (app.component.ts or guard) export class AppComponent { private readonly store = inject(AppStore); private readonly householdService = inject(HouseholdService);

async ngOnInit(): Promise<void> { // Load once at app initialization const household = await this.householdService.getActive(); this.store.setHousehold(household); } }

// home.component.ts export class Home { private readonly store = inject(AppStore); protected readonly household = this.store.household; // No API call }

// tasks.component.ts export class Tasks { private readonly store = inject(AppStore); protected readonly household = this.store.household; // No API call }

Migrating to AsyncState

Before:

export class MyComponent { protected loading = signal(false); protected error = signal<string | null>(null); protected data = signal<Task[]>([]);

async loadData(): Promise<void> { try { this.loading.set(true); this.error.set(null); const result = await this.service.getData(); this.data.set(result); } catch (err) { this.error.set('Failed to load data'); } finally { this.loading.set(false); } } }

After:

export class MyComponent { protected readonly dataState = new AsyncState<Task[]>();

async loadData(): Promise<void> { await this.dataState.execute(() => this.service.getData()); } }

Migrating from localStorage

Before:

// Multiple files accessing localStorage directly const id = localStorage.getItem('activeHouseholdId'); localStorage.setItem('activeHouseholdId', newId);

After:

// Centralized access through StorageService const id = this.storage.get(STORAGE_KEYS.ACTIVE_HOUSEHOLD_ID, z.string()); this.storage.set(STORAGE_KEYS.ACTIVE_HOUSEHOLD_ID, newId);

Testing State Management

Testing Centralized Store

import { TestBed } from '@angular/core/testing'; import { AppStore } from './app.store';

describe('AppStore', () => { let store: AppStore;

beforeEach(() => { TestBed.configureTestingModule({ providers: [AppStore], }); store = TestBed.inject(AppStore); });

it('should update tasks', () => { const tasks = [{ id: '1', name: 'Test' }]; store.setTasks(tasks); expect(store.tasks()).toEqual(tasks); });

it('should compute active tasks', () => { store.setTasks([ { id: '1', active: true }, { id: '2', active: false }, ]); expect(store.activeTasks().length).toBe(1); }); });

Testing AsyncState

import { AsyncState } from './async-state';

describe('AsyncState', () => { it('should handle successful execution', async () => { const state = new AsyncState<string>(); await state.execute(async () => 'success');

expect(state.isSuccess()).toBe(true);
expect(state.data()).toBe('success');

});

it('should handle errors', async () => { const state = new AsyncState<string>(); await state.execute(async () => { throw new Error('Failed'); });

expect(state.isError()).toBe(true);
expect(state.error()).toBe('Failed');

}); });

Testing with StorageService

import { TestBed } from '@angular/core/testing'; import { StorageService } from './storage.service';

describe('Component with StorageService', () => { let storage: StorageService;

beforeEach(() => { // Clear localStorage before each test localStorage.clear();

TestBed.configureTestingModule({
  providers: [StorageService],
});
storage = TestBed.inject(StorageService);

});

it('should save and retrieve data', () => { const data = { test: 'value' }; storage.set('key', data);

const retrieved = storage.get('key', z.object({ test: z.string() }));
expect(retrieved).toEqual(data);

}); });

Common Patterns

Optimistic Updates

@Injectable({ providedIn: 'root' }) export class AppStore { private tasksSignal = signal<Task[]>([]); readonly tasks = this.tasksSignal.asReadonly();

async updateTaskOptimistic( taskId: string, updates: Partial<Task>, apiCall: () => Promise<Task>, ): Promise<void> { // Save current state for rollback const previousTasks = this.tasksSignal();

// Optimistic update
this.tasksSignal.update((tasks) =>
  tasks.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
);

try {
  // Make API call
  await apiCall();
} catch (error) {
  // Rollback on error
  this.tasksSignal.set(previousTasks);
  throw error;
}

} }

Loading with Store

@Injectable({ providedIn: 'root' }) export class AppStore { private readonly loadingSignal = signal(false); readonly isLoading = this.loadingSignal.asReadonly();

async loadWithIndicator<T>(fn: () => Promise<T>): Promise<T> { this.loadingSignal.set(true); try { return await fn(); } finally { this.loadingSignal.set(false); } } }

Success Criteria

Before implementing state management:

  • Identified shared vs local state correctly

  • Centralized store for multi-component data

  • AsyncState used for async operations

  • StorageService used instead of localStorage

  • No duplicate API calls

  • Proper error handling

  • Tests for store logic

  • Optimistic updates where appropriate

References

Project-Specific

  • GitHub Issue #255: State Management

  • GitHub Issue #258: Eliminate Code Duplication (AsyncState)

  • GitHub Issue #259: Abstract localStorage

  • .claude/agents/agent-frontend.md : Complete frontend patterns

Angular 21 State Management (2025)

  • Angular State Management for 2025 | Nx Blog

  • Signals Overview • Angular

  • Best Practices for Using Angular Signals in 2025 | Medium

  • Application State Management with Angular Signals | Medium

  • Practical Guide: State Management Angular Services + Signals | Telerik

NgRx SignalStore

  • NgRx SignalStore Official Docs

  • NgRx Signal Store vs Signal State vs Simple Signal | Medium

  • Using NgRx Signal Store for State Management | DEV Community

  • Modern Angular State Management with NgRx Signals | Medium

Resource APIs

  • Angular Resource and rxResource | Telerik

  • Reactive data fetching with httpResource • Angular

  • Angular's Resource APIs - Let's Fix Them! | Angular.Schule

  • Improve UX with (rx)resource | Tim Deschryver

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

ux-design

No summary provided by upstream source.

Repository SourceNeeds Review
General

frontend-design

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

agent-storybook

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

agent-e2e

No summary provided by upstream source.

Repository SourceNeeds Review