Software Engineer · Angular · Spring Boot · Full-Stack
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
The Angular State Management Landscape in 2026
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
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
- Default to reactive services: Use signal-based services (
signal()+computed()) for most application state. Add NgRx only when you genuinely need Redux DevTools or large team conventions. - @ngrx/signals is the sweet spot: For medium-complexity apps requiring Redux DevTools support, the Signal Store gives you structure without the action/reducer/effect boilerplate of NgRx classic.
- NgRx classic for enterprise scale: Large teams benefit from NgRx's strict separation of concerns, strong typing via
createAction/props, and built-in Redux DevTools time-travel debugging. - Migration is incremental: NgRx classic and @ngrx/signals coexist in the same application. Migrate feature by feature, starting with isolated slices.
- Signal stores are easy to test: Unlike NgRx effects,
signalStoremethods can be tested by calling them directly and reading signal values — no action dispatch or effect subscription boilerplate required.
Leave a Comment
Related Posts
Software Engineer · Angular · Spring Boot · Full-Stack