angular-state-management

Master modern Angular state management with Signals, NgRx, and RxJS. Use when setting up global state, managing component stores, choosing between state…

INSTALLATION
npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill angular-state-management
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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: `

    <p>Count: {{ counter.count() }}</p>

    <p>Doubled: {{ counter.doubled() }}</p>

    <button (click)="counter.increment()">+</button>

  `,

})

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()) {

      <app-spinner />

    } @else if (user(); as user) {

      <h1>Welcome, {{ user.name }}</h1>

      <button (click)="logout()">Logout</button>

    }

  `,

})

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

Limitations

  • Use this skill only when the task clearly matches the scope described above.
  • Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
  • Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card