Skip to main content
Technology & EngineeringAngular447 lines

Angular Ngrx

NgRx state management with store, effects, selectors, and the component store

Quick Summary17 lines
You are an expert in NgRx for building scalable, predictable state management in Angular applications.

## Key Points

1. **Use `createActionGroup` for concise action definitions.** It eliminates repetitive `createAction` calls and enforces consistent naming.
2. **Use functional effects.** The `{ functional: true }` syntax is cleaner, tree-shakable, and aligns with Angular's move toward functions.
3. **Use `selectSignal` in components.** It integrates NgRx state with Angular signals, removing the need for `async` pipe and reducing subscription management.
4. **Use `createFeature` to auto-generate selectors.** It creates selectors for every top-level state property, saving boilerplate.
5. **Use ComponentStore or SignalStore for local state.** Not everything belongs in the global store. Pagination, form state, and UI toggles are better handled locally.
6. **Keep reducers pure and effects lean.** Reducers should only compute new state. Effects should orchestrate side effects and dispatch result actions.
- **Over-using the global store.** Putting everything in NgRx adds overhead. Use the global store for shared, cross-feature state. Use ComponentStore or signals for local state.
- **Dispatching actions in constructors without guards.** This can cause redundant API calls when components re-render. Check if data is already loaded before dispatching.
- **Fat actions vs. fat reducers.** Prefer actions that describe *what happened* (events) rather than *what to do* (commands). Let the reducer decide how state changes.
- **Not using `selectSignal` or `async` pipe.** Manually subscribing to `store.select()` and assigning to properties creates memory leak risk and requires cleanup.
- **Forgetting to register effects.** Effects must be registered via `provideEffects()` at the correct level (root or feature). Unregistered effects simply never run.
skilldb get angular-skills/Angular NgrxFull skill: 447 lines
Paste into your CLAUDE.md or agent config

NgRx State Management — Angular

You are an expert in NgRx for building scalable, predictable state management in Angular applications.

Core Philosophy

NgRx implements the Redux pattern for Angular with a clear separation: actions describe what happened, reducers compute new state, selectors derive data for the UI, and effects handle side effects. This separation is not bureaucratic overhead — it creates a unidirectional data flow where every state change is traceable, reproducible, and debuggable through Redux DevTools. When a bug appears, you can replay the action log to find exactly which event caused the wrong state.

The most important architectural decision with NgRx is knowing what belongs in the global store and what does not. Global store state should be shared across multiple features, needed by routes that are not simultaneously rendered, or required for undo/redo capability. Local UI state (pagination, form inputs, toggle states) belongs in ComponentStore or signals. Putting everything in the global store creates excessive boilerplate and makes simple UI interactions require action/reducer/selector ceremonies that slow development without adding value.

Modern NgRx (v16+) dramatically reduces boilerplate through createFeature (auto-generating selectors), createActionGroup (concise action definitions), functional effects, and selectSignal (bridging NgRx with Angular signals). The signalStore API represents the future direction: a signal-native store that combines the benefits of NgRx's patterns with Angular's signal-based reactivity. New features should strongly consider signalStore before reaching for the traditional action/reducer/effect pattern.

Anti-Patterns

  • Over-Using the Global Store — putting every piece of state into NgRx, including form values, UI toggles, and component-local pagination. This creates massive boilerplate overhead for state that only one component uses. Use ComponentStore or signals for local state.

  • Fat Actions (Commands Instead of Events) — naming actions like SetUsers or UpdateLoadingState instead of event-oriented names like UsersPageOpened or UsersLoadedSuccess. Command-style actions couple the action to a specific state change, defeating the purpose of indirection.

  • Dispatching in Constructors Without Guards — calling store.dispatch(loadProducts()) in ngOnInit without checking if products are already loaded, causing redundant API calls every time the component initializes.

  • Manual Subscriptions to Store Selects — using store.select(...).subscribe(val => this.data = val) instead of selectSignal or the async pipe, creating subscription management burdens and memory leak risks.

  • Unregistered Effects — defining an effect function but forgetting to include it in provideEffects() at the correct level. The effect compiles without error but simply never executes, causing silent data-fetching failures.

Overview

NgRx is a reactive state management library for Angular built on RxJS. It implements the Redux pattern — a single immutable store, actions describing events, reducers computing new state, and selectors deriving data. NgRx also provides Effects for handling side effects and ComponentStore for local component state. In modern NgRx (v16+), the createFeature and signalStore APIs simplify boilerplate significantly.

Core Concepts

Actions

Actions are unique events dispatched from components, services, or effects:

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

export const ProductActions = createActionGroup({
  source: 'Products',
  events: {
    'Load Products': emptyProps(),
    'Load Products Success': props<{ products: Product[] }>(),
    'Load Products Failure': props<{ error: string }>(),
    'Select Product': props<{ productId: string }>(),
    'Delete Product': props<{ productId: string }>(),
    'Delete Product Success': props<{ productId: string }>(),
  },
});

Reducers with createFeature

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

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

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

export const productsFeature = createFeature({
  name: 'products',
  reducer: createReducer(
    initialState,
    on(ProductActions.loadProducts, (state) => ({
      ...state,
      loading: true,
      error: null,
    })),
    on(ProductActions.loadProductsSuccess, (state, { products }) => ({
      ...state,
      products,
      loading: false,
    })),
    on(ProductActions.loadProductsFailure, (state, { error }) => ({
      ...state,
      loading: false,
      error,
    })),
    on(ProductActions.selectProduct, (state, { productId }) => ({
      ...state,
      selectedProductId: productId,
    })),
    on(ProductActions.deleteProductSuccess, (state, { productId }) => ({
      ...state,
      products: state.products.filter(p => p.id !== productId),
    })),
  ),
});

// createFeature auto-generates selectors:
// productsFeature.selectProducts
// productsFeature.selectLoading
// productsFeature.selectError
// productsFeature.selectSelectedProductId

Selectors

Compose selectors for derived data:

// products.selectors.ts
import { createSelector } from '@ngrx/store';
import { productsFeature } from './products.reducer';

export const selectSelectedProduct = createSelector(
  productsFeature.selectProducts,
  productsFeature.selectSelectedProductId,
  (products, selectedId) =>
    selectedId ? products.find(p => p.id === selectedId) ?? null : null,
);

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

export const selectAvailableProducts = createSelector(
  productsFeature.selectProducts,
  (products) => products.filter(p => p.inStock),
);

Effects

Effects handle side effects like HTTP requests:

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

export const loadProducts = createEffect(
  (actions$ = inject(Actions), productService = inject(ProductService)) =>
    actions$.pipe(
      ofType(ProductActions.loadProducts),
      exhaustMap(() =>
        productService.getAll().pipe(
          map(products => ProductActions.loadProductsSuccess({ products })),
          catchError(error =>
            of(ProductActions.loadProductsFailure({ error: error.message }))
          ),
        )
      ),
    ),
  { functional: true }
);

export const deleteProduct = createEffect(
  (actions$ = inject(Actions), productService = inject(ProductService)) =>
    actions$.pipe(
      ofType(ProductActions.deleteProduct),
      mergeMap(({ productId }) =>
        productService.delete(productId).pipe(
          map(() => ProductActions.deleteProductSuccess({ productId })),
          catchError(error =>
            of(ProductActions.loadProductsFailure({ error: error.message }))
          ),
        )
      ),
    ),
  { functional: true }
);

Implementation Patterns

Store Registration

// app.config.ts
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { productsFeature } from './products/state/products.reducer';
import * as productEffects from './products/state/products.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),
    provideEffects(productEffects),
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
  ],
};

// Lazy-loaded feature registration
// products/product.routes.ts
import { provideState } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';

export const PRODUCT_ROUTES: Routes = [
  {
    path: '',
    providers: [
      provideState(productsFeature),
      provideEffects(productEffects),
    ],
    loadComponent: () =>
      import('./product-shell.component').then(m => m.ProductShellComponent),
  },
];

Component Usage with selectSignal

@Component({
  standalone: true,
  imports: [RouterLink],
  template: `
    @if (loading()) {
      <app-spinner />
    } @else if (error()) {
      <app-error [message]="error()!" />
    } @else {
      <ul>
        @for (product of products(); track product.id) {
          <li>
            <a [routerLink]="[product.id]" (click)="select(product.id)">
              {{ product.name }} - {{ product.price | currency }}
            </a>
          </li>
        }
      </ul>
    }
  `,
})
export class ProductListComponent implements OnInit {
  private store = inject(Store);

  products = this.store.selectSignal(productsFeature.selectProducts);
  loading = this.store.selectSignal(productsFeature.selectLoading);
  error = this.store.selectSignal(productsFeature.selectError);

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

  select(productId: string): void {
    this.store.dispatch(ProductActions.selectProduct({ productId }));
  }
}

ComponentStore for Local State

For component-scoped state that does not need to be global:

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { switchMap, tap } from 'rxjs';

interface PaginationState {
  items: Item[];
  page: number;
  pageSize: number;
  total: number;
  loading: boolean;
}

@Injectable()
export class PaginationStore extends ComponentStore<PaginationState> {
  private itemService = inject(ItemService);

  constructor() {
    super({ items: [], page: 1, pageSize: 20, total: 0, loading: false });
  }

  // Selectors
  readonly items = this.selectSignal(s => s.items);
  readonly page = this.selectSignal(s => s.page);
  readonly totalPages = this.selectSignal(s => Math.ceil(s.total / s.pageSize));
  readonly loading = this.selectSignal(s => s.loading);

  // Updaters
  readonly setPage = this.updater((state, page: number) => ({
    ...state,
    page,
  }));

  // Effects
  readonly loadItems = this.effect<{ page: number; pageSize: number }>(
    (params$) =>
      params$.pipe(
        tap(() => this.patchState({ loading: true })),
        switchMap(({ page, pageSize }) =>
          this.itemService.getItems(page, pageSize).pipe(
            tapResponse(
              (response) =>
                this.patchState({
                  items: response.items,
                  total: response.total,
                  loading: false,
                }),
              (error: Error) => {
                console.error(error);
                this.patchState({ loading: false });
              },
            ),
          )
        ),
      )
  );
}
@Component({
  standalone: true,
  providers: [PaginationStore],
  template: `
    @for (item of store.items(); track item.id) {
      <div>{{ item.name }}</div>
    }
    <app-paginator
      [currentPage]="store.page()"
      [totalPages]="store.totalPages()"
      (pageChange)="onPageChange($event)"
    />
  `,
})
export class ItemListComponent implements OnInit {
  protected store = inject(PaginationStore);

  ngOnInit(): void {
    this.store.loadItems({ page: 1, pageSize: 20 });
  }

  onPageChange(page: number): void {
    this.store.setPage(page);
    this.store.loadItems({ page, pageSize: 20 });
  }
}

NgRx SignalStore (v17+)

The newest NgRx API built entirely on signals:

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

type TodoState = {
  todos: Todo[];
  loading: boolean;
  filter: 'all' | 'active' | 'completed';
};

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState<TodoState>({ todos: [], loading: false, filter: 'all' }),
  withComputed((store) => ({
    filteredTodos: computed(() => {
      const filter = store.filter();
      const todos = store.todos();
      switch (filter) {
        case 'active': return todos.filter(t => !t.completed);
        case 'completed': return todos.filter(t => t.completed);
        default: return todos;
      }
    }),
    completedCount: computed(() => store.todos().filter(t => t.completed).length),
  })),
  withMethods((store, todoService = inject(TodoService)) => ({
    setFilter(filter: 'all' | 'active' | 'completed') {
      patchState(store, { filter });
    },
    loadTodos: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true })),
        switchMap(() =>
          todoService.getAll().pipe(
            tapResponse(
              (todos) => patchState(store, { todos, loading: false }),
              (err) => {
                console.error(err);
                patchState(store, { loading: false });
              },
            )
          )
        ),
      )
    ),
    toggleTodo(id: number) {
      patchState(store, {
        todos: store.todos().map(t =>
          t.id === id ? { ...t, completed: !t.completed } : t
        ),
      });
    },
  })),
);

Best Practices

  1. Use createActionGroup for concise action definitions. It eliminates repetitive createAction calls and enforces consistent naming.

  2. Use functional effects. The { functional: true } syntax is cleaner, tree-shakable, and aligns with Angular's move toward functions.

  3. Use selectSignal in components. It integrates NgRx state with Angular signals, removing the need for async pipe and reducing subscription management.

  4. Use createFeature to auto-generate selectors. It creates selectors for every top-level state property, saving boilerplate.

  5. Use ComponentStore or SignalStore for local state. Not everything belongs in the global store. Pagination, form state, and UI toggles are better handled locally.

  6. Keep reducers pure and effects lean. Reducers should only compute new state. Effects should orchestrate side effects and dispatch result actions.

Common Pitfalls

  • Over-using the global store. Putting everything in NgRx adds overhead. Use the global store for shared, cross-feature state. Use ComponentStore or signals for local state.

  • Dispatching actions in constructors without guards. This can cause redundant API calls when components re-render. Check if data is already loaded before dispatching.

  • Fat actions vs. fat reducers. Prefer actions that describe what happened (events) rather than what to do (commands). Let the reducer decide how state changes.

  • Not using selectSignal or async pipe. Manually subscribing to store.select() and assigning to properties creates memory leak risk and requires cleanup.

  • Forgetting to register effects. Effects must be registered via provideEffects() at the correct level (root or feature). Unregistered effects simply never run.

Install this skill directly: skilldb add angular-skills

Get CLI access →