Angular State Management NgRx Signals Services 2026
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Angular · Spring Boot · Full-Stack

Angular April 2, 2026 20 min read Angular Series

Angular State Management: NgRx vs Signals vs Services in 2026

State management is one of the most debated topics in Angular development. With the introduction of Angular Signals in Angular 16 and the @ngrx/signals package in Angular 17, the landscape has fundamentally shifted. The classic NgRx pattern — actions, reducers, effects, selectors — remains powerful for complex enterprise applications, but Signal-based state management offers a simpler mental model for most use cases.

Table of Contents

  1. The Angular State Management Landscape in 2026
  2. NgRx Classic: Actions, Reducers, Effects, Selectors
  3. @ngrx/signals: The Signal Store
  4. BehaviorSubject Services Pattern
  5. When to Use Each Approach
  6. Migration Path from NgRx to Signals
  7. Testing State Management Code

The Angular State Management Landscape in 2026

NgRx State Flow — Actions, Reducers, Effects, Selectors | mdsanwarhossain.me
NgRx State Flow — Actions, Reducers, Effects, Selectors — mdsanwarhossain.me

In 2026, Angular developers have three mature choices for state management: NgRx (classic redux-style), @ngrx/signals (signal-based store), and reactive services using BehaviorSubject or raw signal(). Each occupies a different position on the complexity-vs-power spectrum.

NgRx classic remains the gold standard for large teams with complex shared state, time-travel debugging requirements, and sophisticated async side-effect management. The @ngrx/signals package provides a middle ground — the same Redux DevTools support and team conventions as NgRx, but with a dramatically simpler API built on Angular signals. Plain reactive services remain the right choice for small-to-medium apps or feature-scoped state that doesn't need to be shared across distant parts of the component tree.

The biggest mistake teams make is cargo-culting NgRx into small apps because "it's what you're supposed to use." NgRx adds meaningful boilerplate — for every piece of state you need actions, a reducer case, selectors, and often an effect. For a feature with 5 state properties, that's typically 4–6 files. Signals can express the same thing in one file with 30 lines of code.

NgRx Classic: Actions, Reducers, Effects, Selectors

NgRx classic follows the Redux pattern strictly. The Store holds a single immutable state tree. Components dispatch Actions. Reducers compute the new state from current state + action. Effects intercept actions to perform async work (HTTP calls) and dispatch result actions. Selectors compute derived values from the store with memoization.

// products.actions.ts
import { createAction, props } from '@ngrx/store';

export const loadProducts = createAction('[Products] Load Products');
export const loadProductsSuccess = createAction(
  '[Products] Load Products Success',
  props<{ products: Product[] }>()
);
export const loadProductsFailure = createAction(
  '[Products] Load Products Failure',
  props<{ error: string }>()
);
export const addToCart = createAction(
  '[Products] Add To Cart',
  props<{ productId: number; quantity: number }>()
);

// products.reducer.ts
import { createReducer, on } from '@ngrx/store';

export interface ProductsState {
  products: Product[];
  loading: boolean;
  error: string | null;
}

const initialState: ProductsState = {
  products: [],
  loading: false,
  error: null,
};

export const productsReducer = createReducer(
  initialState,
  on(loadProducts, state => ({ ...state, loading: true, error: null })),
  on(loadProductsSuccess, (state, { products }) =>
    ({ ...state, loading: false, products })),
  on(loadProductsFailure, (state, { error }) =>
    ({ ...state, loading: false, error }))
);

// products.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { switchMap, map, catchError, of } from 'rxjs';

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadProducts),
      switchMap(() =>
        this.productService.getAll().pipe(
          map(products => loadProductsSuccess({ products })),
          catchError(err => of(loadProductsFailure({ error: err.message })))
        )
      )
    )
  );
  constructor(private actions$: Actions, private productService: ProductService) {}
}

// products.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';

const selectProductsState = createFeatureSelector<ProductsState>('products');

export const selectAllProducts = createSelector(
  selectProductsState, state => state.products
);
export const selectProductsLoading = createSelector(
  selectProductsState, state => state.loading
);
export const selectProductsByCategory = (category: string) =>
  createSelector(selectAllProducts, products =>
    products.filter(p => p.category === category)
  );

In the component, the store is injected and state is accessed via selectors using store.selectSignal() (NgRx 17+) for signal-based access:

// product-list.component.ts (NgRx classic)
@Component({ standalone: true, /* ... */ })
export class ProductListComponent {
  private store = inject(Store);

  products = this.store.selectSignal(selectAllProducts);
  loading = this.store.selectSignal(selectProductsLoading);

  ngOnInit() {
    this.store.dispatch(loadProducts());
  }

  addToCart(productId: number) {
    this.store.dispatch(addToCart({ productId, quantity: 1 }));
  }
}

@ngrx/signals: The Signal Store

Angular Signals Store (@ngrx/signals) Architecture | mdsanwarhossain.me
Angular Signals Store (@ngrx/signals) Architecture — mdsanwarhossain.me

The @ngrx/signals package provides signalStore() — a composable store factory that replaces actions, reducers, and selectors with a more direct API. The same state is reactive and integrates with Redux DevTools, but without the boilerplate of explicit actions for every state transition.

// products.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { switchMap, pipe, tap } from 'rxjs';

interface ProductsState {
  products: Product[];
  loading: boolean;
  error: string | null;
  selectedCategory: string | null;
}

const initialState: ProductsState = {
  products: [],
  loading: false,
  error: null,
  selectedCategory: null,
};

export const ProductsStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),

  withComputed(({ products, selectedCategory }) => ({
    filteredProducts: computed(() => {
      const cat = selectedCategory();
      return cat ? products().filter(p => p.category === cat) : products();
    }),
    totalCount: computed(() => products().length),
  })),

  withMethods((store, productService = inject(ProductService)) => ({
    loadProducts: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(() =>
          productService.getAll().pipe(
            tapResponse({
              next: products => patchState(store, { products, loading: false }),
              error: (err: Error) =>
                patchState(store, { error: err.message, loading: false }),
            })
          )
        )
      )
    ),

    selectCategory(category: string | null) {
      patchState(store, { selectedCategory: category });
    },
  }))
);

// Usage in component — zero boilerplate
@Component({ standalone: true, providers: [ProductsStore], /* ... */ })
export class ProductListComponent {
  store = inject(ProductsStore);

  ngOnInit() { this.store.loadProducts(); }
}
// Template: {{ store.filteredProducts() }}, {{ store.loading() }}

Notice the dramatic reduction in boilerplate. There are no separate action files, no reducer switch cases, no effect registration. The store expresses state, computed values, and methods in a single composable unit. patchState() performs immutable state updates with automatic change notifications.

BehaviorSubject Services Pattern

For small-to-medium features or feature-scoped state that doesn't need Redux DevTools, a reactive service with signal() or BehaviorSubject is the simplest solution. This pattern requires zero dependencies beyond Angular core and is the right default for most applications:

// cart.service.ts — signal-based reactive service
@Injectable({ providedIn: 'root' })
export class CartService {
  private http = inject(HttpClient);

  private _items = signal<CartItem[]>([]);
  private _loading = signal(false);

  // Public read-only signals
  items = this._items.asReadonly();
  loading = this._loading.asReadonly();

  // Derived computed values
  totalItems = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0)
  );
  totalPrice = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  addItem(product: Product, quantity = 1) {
    this._items.update(items => {
      const existing = items.find(i => i.productId === product.id);
      if (existing) {
        return items.map(i =>
          i.productId === product.id
            ? { ...i, quantity: i.quantity + quantity }
            : i
        );
      }
      return [...items, { productId: product.id, name: product.name,
                          price: product.price, quantity }];
    });
  }

  removeItem(productId: number) {
    this._items.update(items => items.filter(i => i.productId !== productId));
  }

  clearCart() {
    this._items.set([]);
  }

  checkout() {
    this._loading.set(true);
    return this.http.post('/api/orders', { items: this._items() }).pipe(
      tap(() => { this.clearCart(); this._loading.set(false); }),
    );
  }
}

When to Use Each Approach

The decision framework is based on state complexity, team size, and tooling requirements:

Factor NgRx Classic @ngrx/signals Reactive Service
Team size 10+ devs 5–20 devs 1–10 devs
Complexity High Medium Low
DevTools Full support Full support None
Boilerplate High (5+ files) Low (1 file) Minimal

Migration Path from NgRx to Signals

Migrating from NgRx classic to @ngrx/signals is incremental — both can coexist. Start by migrating isolated feature stores that have no cross-feature action dependencies. The pattern is: create a signalStore(), move state and computed selectors to withState/withComputed, move effects to rxMethod within withMethods, then update components to inject the store directly instead of the NgRx Store.

// Step 1: Create the signal store alongside the NgRx slice
export const ProductsStore = signalStore(
  withState({ products: [] as Product[], loading: false }),
  withMethods((store, service = inject(ProductService)) => ({
    load: rxMethod<void>(pipe(
      switchMap(() => service.getAll().pipe(
        tapResponse({
          next: products => patchState(store, { products }),
          error: () => patchState(store, { loading: false }),
        })
      ))
    )),
  }))
);

// Step 2: Update component to use signal store (feature-flagged during transition)
// Step 3: Remove NgRx actions, reducer, effects for this feature
// Step 4: Remove StoreModule.forFeature('products', ...) registration

Testing State Management Code

Testing signal stores is considerably simpler than testing NgRx — there are no action dispatching seams to test, no effect isolation boilerplate. Test the store by creating an instance and calling its methods directly:

// products.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { ProductsStore } from './products.store';
import { ProductService } from './product.service';
import { of, throwError } from 'rxjs';

describe('ProductsStore', () => {
  let store: InstanceType<typeof ProductsStore>;
  let mockService: jasmine.SpyObj<ProductService>;

  beforeEach(() => {
    mockService = jasmine.createSpyObj('ProductService', ['getAll']);

    TestBed.configureTestingModule({
      providers: [
        ProductsStore,
        { provide: ProductService, useValue: mockService },
      ],
    });
    store = TestBed.inject(ProductsStore);
  });

  it('loads products on loadProducts() call', () => {
    const products = [{ id: 1, name: 'Widget', price: 9.99, category: 'tools' }];
    mockService.getAll.and.returnValue(of(products));

    store.loadProducts();

    expect(store.products()).toEqual(products);
    expect(store.loading()).toBeFalse();
  });

  it('filters products by category', () => {
    const products = [
      { id: 1, name: 'Hammer', price: 12, category: 'tools' },
      { id: 2, name: 'Pen', price: 2, category: 'office' },
    ];
    mockService.getAll.and.returnValue(of(products));
    store.loadProducts();

    store.selectCategory('tools');
    expect(store.filteredProducts().length).toBe(1);
    expect(store.filteredProducts()[0].name).toBe('Hammer');
  });
});

Key Takeaways

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Angular · Spring Boot · Full-Stack

Last updated: April 2, 2026