Skip to main content
NgRx State Management

NgRx Overview

Estimated Time: 4 hours | Difficulty: Advanced | Prerequisites: RxJS, Services, Signals
NgRx is a reactive state management library for Angular applications, inspired by Redux. It provides a single source of truth for application state, making complex data flows predictable and testable.
┌─────────────────────────────────────────────────────────────────────────┐
│                    NgRx Architecture Flow                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   ┌─────────────┐         ┌─────────────┐         ┌─────────────┐      │
│   │  Component  │ ──────► │   Action    │ ──────► │   Reducer   │      │
│   │             │ dispatch│             │         │             │      │
│   └─────────────┘         └─────────────┘         └──────┬──────┘      │
│          ▲                       │                       │              │
│          │                       │                       ▼              │
│          │                       │                ┌─────────────┐      │
│          │                       │                │    Store    │      │
│          │                       │                │   (State)   │      │
│          │                       │                └──────┬──────┘      │
│          │                       │                       │              │
│          │                       ▼                       │              │
│   ┌─────────────┐         ┌─────────────┐               │              │
│   │  Selector   │ ◄────── │    Store    │ ◄─────────────┘              │
│   │             │ select  │   (State)   │                              │
│   └─────────────┘         └─────────────┘                              │
│          │                       │                                      │
│          │                       │ side effects                         │
│          │                       ▼                                      │
│          │                ┌─────────────┐         ┌─────────────┐      │
│          └────────────────│   Effects   │ ──────► │   Service   │      │
│                           │             │   API   │             │      │
│                           └─────────────┘         └─────────────┘      │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Setting Up NgRx

# Install NgRx packages
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools

# Optional: Component Store for local state
npm install @ngrx/component-store

# Generate store scaffolding
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools

Configure Store

// app.config.ts
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { isDevMode } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore({
      // Root reducers
      auth: authReducer,
      ui: uiReducer
    }),
    provideEffects([
      AuthEffects,
      NotificationEffects
    ]),
    provideStoreDevtools({
      maxAge: 25,
      logOnly: !isDevMode(),
      autoPause: true,
      trace: true,
      traceLimit: 75
    }),
    // ... other providers
  ]
};

Actions

Actions describe unique events in your application.
// store/products/products.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Product, ProductFilters } from '../../models/product.model';

export const ProductsActions = createActionGroup({
  source: 'Products',
  events: {
    // Load products
    'Load Products': props<{ filters?: ProductFilters }>(),
    'Load Products Success': props<{ products: Product[] }>(),
    'Load Products Failure': props<{ error: string }>(),
    
    // Single product
    'Load Product': props<{ id: string }>(),
    'Load Product Success': props<{ product: Product }>(),
    'Load Product Failure': props<{ error: string }>(),
    
    // CRUD operations
    'Create Product': props<{ product: Omit<Product, 'id'> }>(),
    'Create Product Success': props<{ product: Product }>(),
    'Create Product Failure': props<{ error: string }>(),
    
    'Update Product': props<{ id: string; changes: Partial<Product> }>(),
    'Update Product Success': props<{ product: Product }>(),
    'Update Product Failure': props<{ error: string }>(),
    
    'Delete Product': props<{ id: string }>(),
    'Delete Product Success': props<{ id: string }>(),
    'Delete Product Failure': props<{ error: string }>(),
    
    // UI actions
    'Select Product': props<{ id: string | null }>(),
    'Set Filters': props<{ filters: ProductFilters }>(),
    'Clear Filters': emptyProps(),
    
    // Optimistic updates
    'Optimistic Update': props<{ id: string; changes: Partial<Product> }>(),
    'Rollback Update': props<{ id: string; original: Product }>()
  }
});

// Cart actions
export const CartActions = createActionGroup({
  source: 'Cart',
  events: {
    'Add Item': props<{ productId: string; quantity: number }>(),
    'Remove Item': props<{ productId: string }>(),
    'Update Quantity': props<{ productId: string; quantity: number }>(),
    'Clear Cart': emptyProps(),
    'Load Cart': emptyProps(),
    'Load Cart Success': props<{ items: CartItem[] }>(),
    'Checkout': emptyProps(),
    'Checkout Success': props<{ orderId: string }>(),
    'Checkout Failure': props<{ error: string }>()
  }
});

Reducers

Reducers are pure functions that handle state transitions.
// store/products/products.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Product, ProductFilters } from '../../models/product.model';
import { ProductsActions } from './products.actions';

// Entity adapter for normalized state
export const adapter: EntityAdapter<Product> = createEntityAdapter<Product>({
  selectId: (product) => product.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name)
});

export interface ProductsState extends EntityState<Product> {
  selectedId: string | null;
  loading: boolean;
  error: string | null;
  filters: ProductFilters;
  totalCount: number;
}

export const initialState: ProductsState = adapter.getInitialState({
  selectedId: null,
  loading: false,
  error: null,
  filters: {},
  totalCount: 0
});

export const productsReducer = createReducer(
  initialState,
  
  // Load products
  on(ProductsActions.loadProducts, (state) => ({
    ...state,
    loading: true,
    error: null
  })),
  
  on(ProductsActions.loadProductsSuccess, (state, { products }) =>
    adapter.setAll(products, {
      ...state,
      loading: false,
      totalCount: products.length
    })
  ),
  
  on(ProductsActions.loadProductsFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error
  })),
  
  // Single product
  on(ProductsActions.loadProductSuccess, (state, { product }) =>
    adapter.upsertOne(product, state)
  ),
  
  // Create
  on(ProductsActions.createProductSuccess, (state, { product }) =>
    adapter.addOne(product, {
      ...state,
      totalCount: state.totalCount + 1
    })
  ),
  
  // Update
  on(ProductsActions.updateProductSuccess, (state, { product }) =>
    adapter.updateOne(
      { id: product.id, changes: product },
      state
    )
  ),
  
  // Optimistic update
  on(ProductsActions.optimisticUpdate, (state, { id, changes }) =>
    adapter.updateOne({ id, changes }, state)
  ),
  
  on(ProductsActions.rollbackUpdate, (state, { id, original }) =>
    adapter.updateOne({ id, changes: original }, state)
  ),
  
  // Delete
  on(ProductsActions.deleteProductSuccess, (state, { id }) =>
    adapter.removeOne(id, {
      ...state,
      totalCount: state.totalCount - 1,
      selectedId: state.selectedId === id ? null : state.selectedId
    })
  ),
  
  // Selection
  on(ProductsActions.selectProduct, (state, { id }) => ({
    ...state,
    selectedId: id
  })),
  
  // Filters
  on(ProductsActions.setFilters, (state, { filters }) => ({
    ...state,
    filters: { ...state.filters, ...filters }
  })),
  
  on(ProductsActions.clearFilters, (state) => ({
    ...state,
    filters: {}
  }))
);

Selectors

Selectors are pure functions for deriving and composing state.
// store/products/products.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductsState, adapter } from './products.reducer';

// Feature selector
export const selectProductsState = createFeatureSelector<ProductsState>('products');

// Entity adapter selectors
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();

// Basic selectors
export const selectAllProducts = createSelector(
  selectProductsState,
  selectAll
);

export const selectProductEntities = createSelector(
  selectProductsState,
  selectEntities
);

export const selectProductIds = createSelector(
  selectProductsState,
  selectIds
);

export const selectProductsLoading = createSelector(
  selectProductsState,
  (state) => state.loading
);

export const selectProductsError = createSelector(
  selectProductsState,
  (state) => state.error
);

export const selectSelectedProductId = createSelector(
  selectProductsState,
  (state) => state.selectedId
);

export const selectProductFilters = createSelector(
  selectProductsState,
  (state) => state.filters
);

// Computed selectors
export const selectSelectedProduct = createSelector(
  selectProductEntities,
  selectSelectedProductId,
  (entities, selectedId) => selectedId ? entities[selectedId] ?? null : null
);

export const selectProductById = (id: string) => createSelector(
  selectProductEntities,
  (entities) => entities[id] ?? null
);

// Filtered products
export const selectFilteredProducts = createSelector(
  selectAllProducts,
  selectProductFilters,
  (products, filters) => {
    let filtered = [...products];
    
    if (filters.category) {
      filtered = filtered.filter(p => p.category === filters.category);
    }
    
    if (filters.minPrice !== undefined) {
      filtered = filtered.filter(p => p.price >= filters.minPrice!);
    }
    
    if (filters.maxPrice !== undefined) {
      filtered = filtered.filter(p => p.price <= filters.maxPrice!);
    }
    
    if (filters.search) {
      const search = filters.search.toLowerCase();
      filtered = filtered.filter(p => 
        p.name.toLowerCase().includes(search) ||
        p.description?.toLowerCase().includes(search)
      );
    }
    
    return filtered;
  }
);

// Grouped products
export const selectProductsByCategory = createSelector(
  selectAllProducts,
  (products) => {
    return products.reduce((acc, product) => {
      const category = product.category || 'Uncategorized';
      if (!acc[category]) {
        acc[category] = [];
      }
      acc[category].push(product);
      return acc;
    }, {} as Record<string, Product[]>);
  }
);

// Statistics
export const selectProductStats = createSelector(
  selectAllProducts,
  (products) => ({
    total: products.length,
    avgPrice: products.length 
      ? products.reduce((sum, p) => sum + p.price, 0) / products.length 
      : 0,
    categories: [...new Set(products.map(p => p.category))].length,
    inStock: products.filter(p => p.stock > 0).length
  })
);

// View model selector (combines multiple selectors)
export const selectProductsViewModel = createSelector(
  selectFilteredProducts,
  selectProductsLoading,
  selectProductsError,
  selectProductFilters,
  selectProductStats,
  (products, loading, error, filters, stats) => ({
    products,
    loading,
    error,
    filters,
    stats,
    hasFilters: Object.keys(filters).length > 0
  })
);

Effects

Effects handle side effects like API calls.
// store/products/products.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { 
  catchError, 
  map, 
  mergeMap, 
  switchMap, 
  withLatestFrom,
  tap,
  exhaustMap,
  concatMap
} from 'rxjs/operators';
import { of } from 'rxjs';
import { ProductsActions } from './products.actions';
import { ProductService } from '../../services/product.service';
import { ToastService } from '../../services/toast.service';
import { selectProductById } from './products.selectors';

@Injectable()
export class ProductsEffects {
  private actions$ = inject(Actions);
  private store = inject(Store);
  private productService = inject(ProductService);
  private toast = inject(ToastService);
  
  // Load products - switchMap cancels previous request
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsActions.loadProducts),
      switchMap(({ filters }) =>
        this.productService.getProducts(filters).pipe(
          map(products => ProductsActions.loadProductsSuccess({ products })),
          catchError(error => of(ProductsActions.loadProductsFailure({ 
            error: error.message 
          })))
        )
      )
    )
  );
  
  // Load single product
  loadProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsActions.loadProduct),
      mergeMap(({ id }) =>
        this.productService.getProduct(id).pipe(
          map(product => ProductsActions.loadProductSuccess({ product })),
          catchError(error => of(ProductsActions.loadProductFailure({ 
            error: error.message 
          })))
        )
      )
    )
  );
  
  // Create product - exhaustMap ignores new requests while one is pending
  createProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsActions.createProduct),
      exhaustMap(({ product }) =>
        this.productService.createProduct(product).pipe(
          map(created => ProductsActions.createProductSuccess({ product: created })),
          catchError(error => of(ProductsActions.createProductFailure({ 
            error: error.message 
          })))
        )
      )
    )
  );
  
  // Update product with optimistic update
  updateProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsActions.updateProduct),
      withLatestFrom(this.store),
      mergeMap(([{ id, changes }, store]) => {
        // Get original for rollback
        const original = selectProductById(id)(store);
        
        // Optimistic update
        this.store.dispatch(ProductsActions.optimisticUpdate({ id, changes }));
        
        return this.productService.updateProduct(id, changes).pipe(
          map(product => ProductsActions.updateProductSuccess({ product })),
          catchError(error => {
            // Rollback on error
            if (original) {
              this.store.dispatch(ProductsActions.rollbackUpdate({ id, original }));
            }
            return of(ProductsActions.updateProductFailure({ error: error.message }));
          })
        );
      })
    )
  );
  
  // Delete product - concatMap maintains order
  deleteProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsActions.deleteProduct),
      concatMap(({ id }) =>
        this.productService.deleteProduct(id).pipe(
          map(() => ProductsActions.deleteProductSuccess({ id })),
          catchError(error => of(ProductsActions.deleteProductFailure({ 
            error: error.message 
          })))
        )
      )
    )
  );
  
  // Success notifications (no dispatch)
  showSuccessToast$ = createEffect(() =>
    this.actions$.pipe(
      ofType(
        ProductsActions.createProductSuccess,
        ProductsActions.updateProductSuccess,
        ProductsActions.deleteProductSuccess
      ),
      tap(action => {
        const messages = {
          '[Products] Create Product Success': 'Product created successfully',
          '[Products] Update Product Success': 'Product updated successfully',
          '[Products] Delete Product Success': 'Product deleted successfully'
        };
        this.toast.success(messages[action.type as keyof typeof messages]);
      })
    ),
    { dispatch: false }
  );
  
  // Error notifications
  showErrorToast$ = createEffect(() =>
    this.actions$.pipe(
      ofType(
        ProductsActions.loadProductsFailure,
        ProductsActions.createProductFailure,
        ProductsActions.updateProductFailure,
        ProductsActions.deleteProductFailure
      ),
      tap(({ error }) => {
        this.toast.error(error);
      })
    ),
    { dispatch: false }
  );
}

Using NgRx in Components

// products/product-list.component.ts
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule, ProductCardComponent, ProductFiltersComponent],
  template: `
    @if (vm$ | async; as vm) {
      <div class="product-list">
        <app-product-filters
          [filters]="vm.filters"
          (filtersChange)="onFiltersChange($event)"
        />
        
        @if (vm.loading) {
          <div class="loading-grid">
            @for (i of [1,2,3,4,5,6]; track i) {
              <div class="skeleton-card"></div>
            }
          </div>
        } @else if (vm.error) {
          <div class="error-state">
            <p>{{ vm.error }}</p>
            <button (click)="retry()">Retry</button>
          </div>
        } @else {
          <div class="stats-bar">
            <span>{{ vm.stats.total }} products</span>
            <span>Avg: {{ vm.stats.avgPrice | currency }}</span>
          </div>
          
          <div class="products-grid">
            @for (product of vm.products; track product.id) {
              <app-product-card
                [product]="product"
                (select)="onSelect($event)"
                (addToCart)="onAddToCart($event)"
              />
            } @empty {
              <div class="empty-state">
                <p>No products found</p>
                @if (vm.hasFilters) {
                  <button (click)="clearFilters()">Clear Filters</button>
                }
              </div>
            }
          </div>
        }
      </div>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductListComponent implements OnInit {
  private store = inject(Store);
  
  vm$ = this.store.select(selectProductsViewModel);
  
  ngOnInit() {
    this.store.dispatch(ProductsActions.loadProducts({}));
  }
  
  onFiltersChange(filters: ProductFilters) {
    this.store.dispatch(ProductsActions.setFilters({ filters }));
    this.store.dispatch(ProductsActions.loadProducts({ filters }));
  }
  
  onSelect(product: Product) {
    this.store.dispatch(ProductsActions.selectProduct({ id: product.id }));
  }
  
  onAddToCart(product: Product) {
    this.store.dispatch(CartActions.addItem({ 
      productId: product.id, 
      quantity: 1 
    }));
  }
  
  clearFilters() {
    this.store.dispatch(ProductsActions.clearFilters());
    this.store.dispatch(ProductsActions.loadProducts({}));
  }
  
  retry() {
    this.store.dispatch(ProductsActions.loadProducts({}));
  }
}

Using Signal Store (NgRx 17+)

// products/products.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { withEntities, setAllEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';

interface ProductsState {
  loading: boolean;
  error: string | null;
  filters: ProductFilters;
  selectedId: string | null;
}

const initialState: ProductsState = {
  loading: false,
  error: null,
  filters: {},
  selectedId: null
};

export const ProductsStore = signalStore(
  { providedIn: 'root' },
  
  withState(initialState),
  withEntities<Product>(),
  
  withComputed((store) => ({
    selectedProduct: computed(() => {
      const id = store.selectedId();
      return id ? store.entityMap()[id] : null;
    }),
    
    filteredProducts: computed(() => {
      const products = store.entities();
      const filters = store.filters();
      
      return products.filter(p => {
        if (filters.category && p.category !== filters.category) return false;
        if (filters.minPrice && p.price < filters.minPrice) return false;
        if (filters.maxPrice && p.price > filters.maxPrice) return false;
        return true;
      });
    }),
    
    stats: computed(() => {
      const products = store.entities();
      return {
        total: products.length,
        avgPrice: products.reduce((sum, p) => sum + p.price, 0) / products.length
      };
    })
  })),
  
  withMethods((store, productService = inject(ProductService)) => ({
    loadProducts: rxMethod<ProductFilters>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap((filters) =>
          productService.getProducts(filters).pipe(
            tapResponse({
              next: (products) => {
                patchState(store, setAllEntities(products));
                patchState(store, { loading: false });
              },
              error: (error: Error) => {
                patchState(store, { loading: false, error: error.message });
              }
            })
          )
        )
      )
    ),
    
    createProduct: rxMethod<Omit<Product, 'id'>>(
      pipe(
        switchMap((product) =>
          productService.createProduct(product).pipe(
            tapResponse({
              next: (created) => patchState(store, addEntity(created)),
              error: console.error
            })
          )
        )
      )
    ),
    
    updateProduct(id: string, changes: Partial<Product>) {
      patchState(store, updateEntity({ id, changes }));
    },
    
    deleteProduct(id: string) {
      patchState(store, removeEntity(id));
    },
    
    selectProduct(id: string | null) {
      patchState(store, { selectedId: id });
    },
    
    setFilters(filters: ProductFilters) {
      patchState(store, { filters });
    }
  }))
);

// Usage in component
@Component({...})
export class ProductsComponent {
  store = inject(ProductsStore);
  
  products = this.store.filteredProducts;
  loading = this.store.loading;
  stats = this.store.stats;
  
  ngOnInit() {
    this.store.loadProducts({});
  }
}

NgRx Entity

Entity adapter for managing collections efficiently.
// Advanced entity operations
export const productsReducer = createReducer(
  initialState,
  
  // Add multiple
  on(ProductsActions.loadProductsSuccess, (state, { products }) =>
    adapter.setAll(products, state)
  ),
  
  // Add one
  on(ProductsActions.createProductSuccess, (state, { product }) =>
    adapter.addOne(product, state)
  ),
  
  // Add many
  on(ProductsActions.bulkImportSuccess, (state, { products }) =>
    adapter.addMany(products, state)
  ),
  
  // Upsert (add or update)
  on(ProductsActions.syncProductSuccess, (state, { product }) =>
    adapter.upsertOne(product, state)
  ),
  
  // Upsert many
  on(ProductsActions.syncProductsSuccess, (state, { products }) =>
    adapter.upsertMany(products, state)
  ),
  
  // Update one
  on(ProductsActions.updateProductSuccess, (state, { product }) =>
    adapter.updateOne({ id: product.id, changes: product }, state)
  ),
  
  // Update many
  on(ProductsActions.bulkUpdateSuccess, (state, { updates }) =>
    adapter.updateMany(
      updates.map(u => ({ id: u.id, changes: u.changes })),
      state
    )
  ),
  
  // Remove one
  on(ProductsActions.deleteProductSuccess, (state, { id }) =>
    adapter.removeOne(id, state)
  ),
  
  // Remove many
  on(ProductsActions.bulkDeleteSuccess, (state, { ids }) =>
    adapter.removeMany(ids, state)
  ),
  
  // Remove by predicate
  on(ProductsActions.removeOutOfStock, (state) =>
    adapter.removeMany(
      (product) => product.stock === 0,
      state
    )
  ),
  
  // Map (transform all entities)
  on(ProductsActions.applyDiscount, (state, { percentage }) =>
    adapter.map(
      (product) => ({
        ...product,
        price: product.price * (1 - percentage / 100)
      }),
      state
    )
  )
);

Best Practices

Feature Stores

Organize state by feature with lazy-loaded reducers

Facade Pattern

Create facades to simplify store interactions

Selector Composition

Build complex selectors from simple ones

Effect Operators

Choose the right flattening operator for each use case
┌─────────────────────────────────────────────────────────────────────────┐
│              Effect Operator Decision Guide                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   switchMap   → Use for read operations (search, load)                  │
│                 Cancels previous request when new one comes             │
│                 Example: Search autocomplete, navigation                │
│                                                                          │
│   mergeMap    → Use for parallel operations that don't conflict         │
│                 All requests run simultaneously                          │
│                 Example: Adding items to favorites                       │
│                                                                          │
│   concatMap   → Use when order matters                                  │
│                 Queues requests sequentially                             │
│                 Example: Ordered transactions, uploads                   │
│                                                                          │
│   exhaustMap  → Use to prevent duplicate submissions                    │
│                 Ignores new requests while one is pending               │
│                 Example: Form submissions, payments                      │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Practice Exercise

1

Build Shopping Cart

Implement a complete cart with NgRx including persistence
2

Add Undo/Redo

Implement undo/redo functionality using NgRx
3

Migrate to Signal Store

Convert a traditional NgRx store to Signal Store

Next: Angular Animations

Create fluid animations and transitions with Angular’s animation system