From angular-plugin
State management for Angular 18-21: signals (signal/computed/effect), services-as-state, NgRx Store + Effects + Selectors, NgRx Component Store, NgRx Signals (newer signal-based store). RxJS essentials — operators, async pipe, takeUntilDestroyed, signal/observable interop. Use this skill to: - Pick the right state tool (signals / services / NgRx variant / vue-query equivalent). - Use signals correctly (signal/computed/effect — when each). - Build a Pinia-style service-as-state singleton. - Set up NgRx Store + Effects + Selectors. - Use RxJS without leaking subscriptions (async pipe, takeUntilDestroyed, Subject patterns). - Bridge signals ↔ observables via toSignal / toObservable. Do NOT use this skill for: - General Angular conventions (see angular-conventions). - Routing state (see angular-routing). - Form state (see angular-forms). - Testing state (see angular-testing).
How this skill is triggered — by the user, by Claude, or both
Slash command
/angular-plugin:angular-state-and-rxThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Modern Angular era (17+) introduces signals as a first-class reactivity primitive alongside RxJS. Both have a place — signals для component state, RxJS для async streams. Plus optional NgRx variants for complex apps.
Modern Angular era (17+) introduces signals as a first-class reactivity primitive alongside RxJS. Both have a place — signals для component state, RxJS для async streams. Plus optional NgRx variants for complex apps.
| Need | Tool |
|---|---|
| Local component state (count, isOpen) | signal() |
| Computed from other state | computed() |
| Side effects on signal change | effect() (sparingly) |
| Cross-component shared state | @Injectable({ providedIn: 'root' }) service з signals |
| Server data with caching | TanStack Query Angular OR custom RxJS service з shareReplay |
| Complex domain state з time-travel debugging | NgRx Store + Effects + Selectors |
| Lightweight per-component reactive store | NgRx Component Store |
| Signal-based store API | @ngrx/signals (Angular 17+) |
| URL state | ActivatedRoute snapshot / queryParams |
Detect what's installed; mirror project's choice; never introduce a new state lib without BA approval.
import { signal, computed, effect } from '@angular/core';
count = signal(0);
double = computed(() => this.count() * 2);
constructor() {
effect(() => {
console.log('count is now', this.count());
});
}
increment() {
this.count.update((c) => c + 1); // functional update — best for derivations
}
set(value: number) {
this.count.set(value); // direct set
}
reset() {
this.count.set(0);
}
signal(initial) vs computed(fn) vs effect(fn)signal() — writable, returns a function. Read with signal(), write with signal.set() or signal.update().computed() — read-only, derived from other signals. Auto-tracks dependencies (calls inside the function are observed). Memoized — recomputes only when deps change.effect() — runs after component init and re-runs when read signals change. Use SPARINGLY — most reactivity flows through templates (auto-track via {{ signal() }}).effect()computed().import { input, output, computed } from '@angular/core';
@Component({ ... })
export class UserCardComponent {
user = input.required<User>(); // required input — InputSignal<User>
showActions = input(false); // optional with default — InputSignal<boolean>
delete = output<string>(); // OutputEmitterRef<string>
isAdmin = computed(() => this.user().role === 'admin');
onDelete() {
this.delete.emit(this.user().id);
}
}
For new components in Angular 17.1+, prefer input() / output() over @Input() / @Output() decorators. Mirror existing project style if all components still use decorators.
// users.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { User } from './user.model';
@Injectable({ providedIn: 'root' })
export class UsersService {
private http = inject(HttpClient);
// Private writable signals
private _users = signal<User[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
// Public readonly signals
users = this._users.asReadonly();
loading = this._loading.asReadonly();
error = this._error.asReadonly();
// Public computed
count = computed(() => this._users().length);
hasError = computed(() => this._error() !== null);
async loadUsers() {
this._loading.set(true);
this._error.set(null);
try {
const users = await firstValueFrom(this.http.get<User[]>('/api/users'));
this._users.set(users);
} catch (err) {
this._error.set(err instanceof Error ? err.message : 'Failed to load');
} finally {
this._loading.set(false);
}
}
addUser(user: User) {
this._users.update((users) => [...users, user]);
}
removeUser(id: string) {
this._users.update((users) => users.filter((u) => u.id !== id));
}
}
asReadonly() exposes a read-only view — consumers can't .set() on it. Internal mutations stay encapsulated.
Component consumers:
@Component({
template: `
@if (usersService.loading()) {
<p>Loading...</p>
} @else {
<p>{{ usersService.count() }} users</p>
<ul>
@for (user of usersService.users(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
}
`
})
export class UsersListComponent {
usersService = inject(UsersService);
ngOnInit() {
this.usersService.loadUsers();
}
}
Angular still uses RxJS heavily — HttpClient returns Observables, ActivatedRoute params/queryParams are Observables, NgRx Effects are Observables.
import { map, filter, switchMap, mergeMap, exhaustMap, debounceTime, distinctUntilChanged, tap, catchError, finalize, takeUntil, take, shareReplay } from 'rxjs';
this.search$.pipe(
debounceTime(300), // wait 300ms after last input
distinctUntilChanged(), // skip duplicates
switchMap((query) => this.searchService.search(query)), // cancel prior on new input
catchError((err) => of([])), // error fallback
);
| Operator | Use |
|---|---|
map(fn) | Transform values |
filter(fn) | Keep matching values |
tap(fn) | Side effect (logging) without changing stream |
switchMap(fn) | Cancel previous inner observable on new outer (search, autocomplete) |
mergeMap(fn) | Run all inner observables in parallel |
concatMap(fn) | Sequential — wait for prior to complete |
exhaustMap(fn) | Ignore new outer while inner pending (login, save buttons) |
debounceTime(ms) | Wait quiet period before emitting |
distinctUntilChanged() | Skip consecutive duplicates |
take(n) | Take first n then complete |
takeUntil(notifier$) | Stop when notifier emits (manual cleanup pattern) |
shareReplay({ bufferSize: 1, refCount: true }) | Cache last value for late subscribers (HTTP caching pattern) |
catchError(fn) | Recover from errors |
finalize(fn) | Cleanup on complete or error |
async pipe in templates@if (users$ | async; as users) {
@for (user of users; track user.id) {
<p>{{ user.name }}</p>
}
} @else {
<p>Loading...</p>
}
async pipe handles subscribe + unsubscribe automatically. Always prefer over manual subscribe() in component classes.
takeUntilDestroyed() (Angular 16+)For when you need subscribe() directly:
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { inject, Component, OnInit } from '@angular/core';
@Component({ ... })
export class UserDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
ngOnInit() {
this.route.params
.pipe(takeUntilDestroyed()) // automatic cleanup tied to component
.subscribe((params) => {
// ...
});
}
}
takeUntilDestroyed() MUST be called in injection context — class field initializer or constructor. If called inside ngOnInit(), you must pass an explicit DestroyRef:
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.someStream$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);
}
Replaces the older manual pattern:
// ❌ Pre-16 — verbose, easy to forget
private destroy$ = new Subject<void>();
ngOnInit() {
this.something$.pipe(takeUntil(this.destroy$)).subscribe(...);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
firstValueFrom / lastValueFromFor one-shot observables (HTTP), convert to Promise:
import { firstValueFrom } from 'rxjs';
async loadUser(id: string) {
return await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
}
Replaces deprecated .toPromise().
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// Observable → Signal
@Injectable({ providedIn: 'root' })
export class UsersService {
private http = inject(HttpClient);
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
}
// Signal → Observable
const filter = signal('');
const filter$ = toObservable(filter); // emits whenever filter() changes
filter$.pipe(
debounceTime(300),
switchMap((q) => this.search(q)),
).subscribe(...);
toSignal requires either initialValue OR requireSync: true (which throws if observable doesn't emit synchronously). Most HTTP cases need initialValue.
@ngrx/store detected)pnpm add @ngrx/store @ngrx/effects @ngrx/store-devtools @ngrx/entity
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideStore({ users: usersReducer }),
provideEffects([UsersEffects]),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
});
// users.actions.ts
import { createAction, createActionGroup, props, emptyProps } from '@ngrx/store';
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Load Users': emptyProps(),
'Load Users Success': props<{ users: User[] }>(),
'Load Users Failure': props<{ error: string }>(),
'Delete User': props<{ id: string }>(),
},
});
// Dispatch
this.store.dispatch(UsersActions.loadUsers());
this.store.dispatch(UsersActions.deleteUser({ id: '123' }));
// users.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { UsersActions } from './users.actions';
export interface UsersState {
items: User[];
loading: boolean;
error: string | null;
}
const initialState: UsersState = { items: [], loading: false, error: null };
export const usersReducer = createReducer(
initialState,
on(UsersActions.loadUsers, (state) => ({ ...state, loading: true, error: null })),
on(UsersActions.loadUsersSuccess, (state, { users }) => ({ ...state, loading: false, items: users })),
on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, loading: false, error })),
on(UsersActions.deleteUser, (state, { id }) => ({ ...state, items: state.items.filter((u) => u.id !== id) })),
);
// users.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import type { UsersState } from './users.reducer';
export const selectUsersState = createFeatureSelector<UsersState>('users');
export const selectAllUsers = createSelector(
selectUsersState,
(state) => state.items
);
export const selectUserCount = createSelector(
selectAllUsers,
(users) => users.length
);
export const selectUserById = (id: string) => createSelector(
selectAllUsers,
(users) => users.find((u) => u.id === id)
);
// users.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { UsersActions } from './users.actions';
@Injectable()
export class UsersEffects {
private actions$ = inject(Actions);
private http = inject(HttpClient);
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadUsers),
switchMap(() =>
this.http.get<User[]>('/api/users').pipe(
map((users) => UsersActions.loadUsersSuccess({ users })),
catchError((err) => of(UsersActions.loadUsersFailure({ error: err.message })))
)
)
)
);
}
@Component({...})
export class UsersListComponent implements OnInit {
private store = inject(Store);
users = this.store.selectSignal(selectAllUsers); // signal-based selector (NgRx 17+)
count = this.store.selectSignal(selectUserCount);
ngOnInit() {
this.store.dispatch(UsersActions.loadUsers());
}
}
store.selectSignal(selector) returns a Signal<T> — read with users() in template.
For Observable-based: users$ = this.store.select(selectAllUsers) + async pipe.
For feature-local state too small for full Store pattern:
import { ComponentStore } from '@ngrx/component-store';
interface PaginationState {
page: number;
pageSize: number;
total: number;
}
@Injectable()
export class PaginationStore extends ComponentStore<PaginationState> {
constructor() {
super({ page: 1, pageSize: 10, total: 0 });
}
readonly page$ = this.select((s) => s.page);
readonly totalPages$ = this.select((s) => Math.ceil(s.total / s.pageSize));
readonly setPage = this.updater((state, page: number) => ({ ...state, page }));
readonly loadPage = this.effect((trigger$: Observable<void>) =>
trigger$.pipe(
switchMap(() => this.api.fetchPage(this.get().page).pipe(
tapResponse(
(data) => this.patchState({ total: data.total }),
(err) => console.error(err)
)
))
)
);
}
// In component
providers: [PaginationStore]
Provided per-component (not providedIn: 'root') — unique instance.
@ngrx/signals provides a signal-based store API. Less verbose than full Store + Effects:
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export const UsersStore = signalStore(
{ providedIn: 'root' },
withState<{ items: User[]; loading: boolean }>({ items: [], loading: false }),
withComputed((state) => ({
count: computed(() => state.items().length),
})),
withMethods((state, http = inject(HttpClient)) => ({
async loadUsers() {
patchState(state, { loading: true });
const items = await firstValueFrom(http.get<User[]>('/api/users'));
patchState(state, { items, loading: false });
},
}))
);
// Use
const store = inject(UsersStore);
store.count(); // signal
store.loadUsers(); // method
Drop-in for many feature-state cases. Less boilerplate than full NgRx Store.
effect() for derived state — use computed().this.someService.value() inside effect() then calling this.someService.update() from same effect — circular update warning.*ngIf="user" instead of *ngIf="user()") — common bug, doesn't unwrap.NgRx Store.select() then mutating result — selectors return immutable references.provideStore / provideEffects in standalone bootstrap — runtime errors.toSignal/toObservable bridges.BehaviorSubject exposed publicly without .asObservable() — consumers can .next().Subject<void> cleanup pattern in Angular 16+ — use takeUntilDestroyed().npx claudepluginhub aratkruglik/claude-sdlc --plugin angular-pluginGuides 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.
Implements NgRx Store (Redux pattern) for global state or NgRx SignalStore for local, signal-based state in Angular apps. Helps choose the right tool for the complexity level.
Provides guidance on Angular Signals for reactive state management with writable signals, computed signals, and effects in Angular 16+ applications.