Back to blog
angularadvanced

Angular State Management with NgRx

Manage complex application state with NgRx — Store, Actions, Reducers, Selectors, and Effects. Includes the modern NgRx Signal Store for simpler use cases.

LearnixoApril 16, 20266 min read
AngularNgRxState ManagementReduxEffectsAdvanced
Share:𝕏

NgRx brings Redux-style state management to Angular. When your app has complex shared state, async data flows, or you need time-travel debugging, NgRx provides structure and predictability that simple services can't match.


When to Use NgRx

Use NgRx when:

  • State is shared across many unrelated components
  • Complex async flows with loading/error/success states
  • You need optimistic updates with rollback
  • Multiple data sources update the same state

Use a service with signals instead when:

  • State is owned by a single feature
  • Simple CRUD with a straightforward data flow
  • Team is small and NgRx overhead isn't worth it

Install NgRx

Bash
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools   # optional  browser extension for time-travel debugging

Core Concepts

Action     → describes what happened ("Load Products", "Add to Cart")
Reducer    → produces the new state given action + old state (pure function)
Selector   → reads a slice of state (memoized)
Effect     → handles side effects — API calls, navigation, etc.
Store      → the single source of truth, holds all state

Step 1 — Define State and Actions

TYPESCRIPT
// products/product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from '../models/product.model';

export const loadProducts        = createAction('[Products] Load Products');
export const loadProductsSuccess = createAction('[Products] Load Success', props<{ products: Product[] }>());
export const loadProductsFailure = createAction('[Products] Load Failure', props<{ error: string }>());
export const addProduct          = createAction('[Products] Add', props<{ product: Product }>());
export const deleteProduct       = createAction('[Products] Delete', props<{ id: number }>());

Step 2 — Define State and Reducer

TYPESCRIPT
// products/product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as ProductActions from './product.actions';
import { Product } from '../models/product.model';

export interface ProductState {
  products: Product[];
  isLoading: boolean;
  error: string | null;
}

export const initialState: ProductState = {
  products:  [],
  isLoading: false,
  error:     null,
};

export const productReducer = createReducer(
  initialState,

  on(ProductActions.loadProducts, state => ({
    ...state,
    isLoading: true,
    error: null,
  })),

  on(ProductActions.loadProductsSuccess, (state, { products }) => ({
    ...state,
    products,
    isLoading: false,
  })),

  on(ProductActions.loadProductsFailure, (state, { error }) => ({
    ...state,
    error,
    isLoading: false,
  })),

  on(ProductActions.addProduct, (state, { product }) => ({
    ...state,
    products: [...state.products, product],
  })),

  on(ProductActions.deleteProduct, (state, { id }) => ({
    ...state,
    products: state.products.filter(p => p.id !== id),
  })),
);

Step 3 — Selectors

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

export const selectProductState = createFeatureSelector<ProductState>('products');

export const selectAllProducts  = createSelector(selectProductState, s => s.products);
export const selectIsLoading    = createSelector(selectProductState, s => s.isLoading);
export const selectError        = createSelector(selectProductState, s => s.error);

// Derived selectors (memoized — only recompute when inputs change)
export const selectActiveProducts = createSelector(
  selectAllProducts,
  products => products.filter(p => p.active),
);

export const selectProductById = (id: number) => createSelector(
  selectAllProducts,
  products => products.find(p => p.id === id),
);

export const selectProductCount = createSelector(
  selectAllProducts,
  products => products.length,
);

Step 4 — Effects

Effects handle async operations triggered by actions:

TYPESCRIPT
// products/product.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { inject } from '@angular/core';
import { switchMap, map, catchError, of } from 'rxjs';
import { ProductService } from '../services/product.service';
import * as ProductActions from './product.actions';

@Injectable()
export class ProductEffects {
  private actions$ = inject(Actions);
  private productService = inject(ProductService);

  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.loadProducts),
      switchMap(() =>
        this.productService.getAll().pipe(
          map(products => ProductActions.loadProductsSuccess({ products })),
          catchError(error => of(ProductActions.loadProductsFailure({ error: error.message }))),
        ),
      ),
    ),
  );
}

Step 5 — Register in app.config.ts

TYPESCRIPT
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { productReducer } from './products/product.reducer';
import { ProductEffects } from './products/product.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideStore({ products: productReducer }),
    provideEffects([ProductEffects]),
    provideStoreDevtools({ maxAge: 25, logOnly: false }),
  ],
};

Step 6 — Use in Components

TYPESCRIPT
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { AsyncPipe } from '@angular/common';
import { inject } from '@angular/core';
import { loadProducts, deleteProduct } from './product.actions';
import { selectAllProducts, selectIsLoading, selectError } from './product.selectors';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (isLoading$ | async) {
      <p>Loading products...</p>
    }
    @if (error$ | async; as error) {
      <p class="error">{{ error }}</p>
    }
    @for (product of products$ | async; track product.id) {
      <div>
        <h3>{{ product.name }}</h3>
        <button (click)="delete(product.id)">Delete</button>
      </div>
    }
  `,
})
export class ProductListComponent implements OnInit {
  private store = inject(Store);

  products$  = this.store.select(selectAllProducts);
  isLoading$ = this.store.select(selectIsLoading);
  error$     = this.store.select(selectError);

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

  delete(id: number): void {
    this.store.dispatch(deleteProduct({ id }));
  }
}

NgRx Signal Store (Modern — Lighter Alternative)

For feature-level state, @ngrx/signals is much lighter than the full Store:

Bash
ng add @ngrx/signals
TYPESCRIPT
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
import { inject } from '@angular/core';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { switchMap, tap } from 'rxjs';

export const ProductStore = signalStore(
  { providedIn: 'root' },

  withState({
    products:  [] as Product[],
    isLoading: false,
    error:     null as string | null,
  }),

  withComputed(({ products }) => ({
    activeProducts: computed(() => products().filter(p => p.active)),
    totalCount:     computed(() => products().length),
  })),

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

    addProduct(product: Product): void {
      patchState(store, { products: [...store.products(), product] });
    },

    removeProduct(id: number): void {
      patchState(store, { products: store.products().filter(p => p.id !== id) });
    },
  })),
);

Using in a component:

TYPESCRIPT
@Component({ /* ... */ })
export class ProductListComponent {
  store = inject(ProductStore);

  ngOnInit(): void {
    this.store.loadProducts();
  }

  delete(id: number): void {
    this.store.removeProduct(id);
  }
}
HTML
@if (store.isLoading()) { <p>Loading...</p> }
@for (product of store.activeProducts(); track product.id) {
  <p>{{ product.name }}</p>
}
<p>Total: {{ store.totalCount() }}</p>

When to Use Which

| Scenario | Recommendation | |---|---| | Simple feature with a few components | Service + Signals | | Feature with complex async flows | NgRx Signal Store | | App-wide shared state (auth, cart, notifications) | NgRx Store | | You already have NgRx in the project | Stick with NgRx Store |


Quick Reference

Install:     ng add @ngrx/store @ngrx/effects
Actions:     createAction('[Feature] Name', props<{ data: Type }>())
Reducer:     createReducer(initialState, on(action, (state, props) => newState))
Selector:    createFeatureSelector + createSelector
Effect:      createEffect(() => actions$.pipe(ofType(...), switchMap(...)))
Register:    provideStore({ key: reducer }), provideEffects([Effects])
Dispatch:    store.dispatch(myAction({ id }))
Select:      store.select(mySelector) → Observable

Signal Store:
  signalStore + withState + withMethods + withComputed
  patchState(store, { field: value })
  store.field()  ← signal access (not Observable)

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.