angular-state-management

精通Angular现代状态管理:Signals、NgRx与RxJS。适用于全局状态配置、组件存储管理、状态方案选型及传统模式迁移等场景。

查看详情
name:angular-state-managementdescription: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.risk:safesource:self

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

    TypeDescriptionSolutions
    Local StateComponent-specific, UI stateSignals, signal()
    Shared StateBetween related componentsSignal services
    Global StateApp-wide, complexNgRx, Akita, Elf
    Server StateRemote data, cachingNgRx Query, RxAngular
    URL StateRoute parametersActivatedRoute
    Form StateInput values, validationReactive 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

    PracticeWhy
    Use Signals for local stateSimple, reactive, no subscriptions
    Use computed() for derived dataAuto-updates, memoized
    Colocate state with featureEasier to maintain
    Use NgRx for complex flowsActions, effects, devtools
    Prefer inject() over constructorCleaner, works in factories

    Don'ts

    Anti-PatternInstead
    Store derived dataUse computed()
    Mutate signals directlyUse set() or update()
    Over-globalize stateKeep local when possible
    Mix RxJS and Signals chaoticallyChoose primary, bridge with toSignal/toObservable
    Subscribe in components for stateUse 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

  • Angular Signals Guide

  • NgRx Documentation

  • NgRx SignalStore

  • RxAngular