Loading...
Loading...
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.
npx skill4agent add sickn33/antigravity-awesome-skills angular-state-managementreact-state-management| Type | Description | Solutions |
|---|---|---|
| Local State | Component-specific, UI state | Signals, |
| 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 |
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// 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);
}// 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);
}
}// 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();
}
}// 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 }),
],
});// 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({
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());
}
}// 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),
),
),
),
);
}// 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 }));
}
}
}| Practice | Why |
|---|---|
| Use Signals for local state | Simple, reactive, no subscriptions |
Use | Auto-updates, memoized |
| Colocate state with feature | Easier to maintain |
| Use NgRx for complex flows | Actions, effects, devtools |
Prefer | Cleaner, works in factories |
| Anti-Pattern | Instead |
|---|---|
| Store derived data | Use |
| Mutate signals directly | Use |
| Over-globalize state | Keep local when possible |
| Mix RxJS and Signals chaotically | Choose primary, bridge with |
| Subscribe in components for state | Use template with 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);
}
}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}`))
);
}