NgRx Store Study Guide

This guide teaches NgRx using a single e-commerce app with Products, Cart, Auth, and Orders. Every topic includes actual NgRx source code and a component wiring example so you can see the pattern in real Angular 16-17 apps.

store/ ├── actions/ ├── reducers/ ├── selectors/ ├── effects/ └── index.ts

Main data flow

Component
→ dispatch(Action) →
Reducer
→
State
→ Selector →
Component

Async side-effect flow

Effect
→
API Call
→
Success / Failure Action
→
Reducer
Store
Single source of truth for app state.
Actions
Events describing what happened.
Reducers
Pure functions that calculate next state.
Selectors
Reusable queries over state.
Effects
Side-effect handlers for async work.

1. NgRx Big Picture

Foundation

NgRx solves the moment when state stops being easy to manage with plain services and Subjects. In an e-commerce app, product lists, cart totals, auth state, and order history all need predictable updates without components mutating shared objects in random places.

Use NgRx when many screens depend on the same state, side effects must be consistent, and debugging state transitions matters. Stay with service-based state when a feature is small, isolated, and not shared widely.

NgRx Code
// store/index.ts
// Root state mirrors the e-commerce app's business domains.
export interface AppState {
  products: ProductsState;
  cart: CartState;
  auth: AuthState;
  orders: OrdersState;
}

// NgRx works best when state is organized by business feature,
// not by UI component tree depth.
export const reducers: ActionReducerMap<AppState> = {
  products: productsReducer,
  cart: cartReducer,
  auth: authReducer,
  orders: ordersReducer,
};
Component / Usage Code
@Component({
  selector: 'app-header-cart-summary',
  template: `
    <button (click)="openCart()">
      Cart ({{ cartCount$ | async }})
    </button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderCartSummaryComponent {
  // Component does not know how totals are computed.
  // It only selects derived state from the store.
  readonly cartCount$ = this.store.select(selectCartItemCount);

  constructor(private readonly store: Store) {}

  openCart(): void {
    this.store.dispatch(UiActions.cartDrawerOpened());
  }
}
Common MistakeAdding NgRx to a tiny feature just because it is popular creates more ceremony than value.
Pro TipAdopt NgRx first in the most shared or async-heavy feature, such as products or cart, then expand naturally.

2. Setting Up NgRx Store

Bootstrap

Setup differs slightly between classic NgModule apps and standalone Angular apps. The core idea stays the same: register root reducers once, then attach feature state near the feature that owns it.

For Angular 16-17, you should know both StoreModule.forRoot() and provideStore() because real teams often have mixed codebases during migration.

NgRx Code
// Install
// npm i @ngrx/store @ngrx/effects @ngrx/store-devtools @ngrx/entity @ngrx/router-store

// app.module.ts
@NgModule({
  imports: [
    BrowserModule,
    StoreModule.forRoot(reducers),
    EffectsModule.forRoot([AuthEffects]),
    StoreModule.forFeature(productsFeatureKey, productsReducer),
  ],
})
export class AppModule {}

// main.ts - standalone Angular 16-17
bootstrapApplication(AppComponent, {
  providers: [
    provideStore(reducers),
    provideEffects([AuthEffects, ProductsEffects]),
    provideState(productsFeature),
  ],
});
Component / Usage Code
// app.config.ts for standalone apps can keep registration close to bootstrap.
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideRouter(routes),
    provideStore(),
    provideState(cartFeature),
    provideEffects([CartEffects]),
  ],
};

@Component({
  selector: 'app-root',
  template: `<app-shell />`,
})
export class AppComponent {}
Common MistakeRegistering the same feature reducer in both root and feature scopes causes duplicate state keys and confusion.
Pro TipPrefer feature creators like createFeature() with provideState() for cleaner standalone setup and built-in selectors.

3. Actions

Events

Actions describe facts that happened in the system. In a shopping app, “Add To Cart Clicked” or “Products Load Succeeded” is clearer than vague commands because you can reconstruct the business story from the action log.

Think of actions as domain events, not direct instructions to reducers. That keeps features decoupled and makes effects easier to reason about.

NgRx Code
// store/actions/cart.actions.ts
export const addToCart = createAction(
  '[Product List] Add To Cart Clicked',
  props<{ productId: string }>()
);

export const cartCleared = createAction('[Checkout Page] Cart Cleared');

export const ProductsApiActions = createActionGroup({
  source: 'Products API',
  events: {
    'Load Started': emptyProps(),
    'Load Succeeded': props<{ products: Product[] }>(),
    'Load Failed': props<{ error: string }>(),
  },
});

// Event style is better than command style because multiple parts
// of the app can react to the same business event.
Component / Usage Code
@Component({
  selector: 'app-product-card',
  template: `
    <article>
      <h3>{{ product.name }}</h3>
      <button (click)="add()">Add to cart</button>
    </article>
  `,
})
export class ProductCardComponent {
  @Input({ required: true }) product!: Product;

  constructor(private readonly store: Store) {}

  add(): void {
    // Dispatch from UI boundary and keep payload small and serializable.
    this.store.dispatch(addToCart({ productId: this.product.id }));
  }
}
Common MistakeCreating actions like “Set Entire Cart From Component” leaks UI decisions into your domain model.
Pro TipUse the [Source] Event naming pattern consistently, because DevTools become much easier to scan.

4. Reducers

Pure State Updates

Reducers are pure functions that return new state objects from old state plus an action. They are the safest place to encode business rules like cart quantity increments or auth logout resets because no async logic lives there.

Immutability matters because selectors and Angular change detection rely on new references. If you mutate arrays or nested objects in place, state changes become invisible and bugs get subtle.

NgRx Code
export interface CartItem {
  productId: string;
  quantity: number;
}

export interface CartState {
  items: CartItem[];
  couponCode: string | null;
}

export const initialCartState: CartState = {
  items: [],
  couponCode: null,
};

export const cartReducer = createReducer(
  initialCartState,
  on(addToCart, (state, { productId }) => {
    const existing = state.items.find(item => item.productId === productId);

    if (existing) {
      return {
        ...state,
        items: state.items.map(item =>
          item.productId === productId
            ? { ...item, quantity: item.quantity + 1 }
            : item
        ),
      };
    }

    return {
      ...state,
      items: [...state.items, { productId, quantity: 1 }],
    };
  }),
  on(cartCleared, logoutSucceeded, () => initialCartState)
);

// Never do this: state.items.push(...)
// Mutation breaks memoization and predictable state history.
Component / Usage Code
@Component({
  selector: 'app-cart-page',
  template: `
    <ng-container *ngIf="items$ | async as items">
      <div *ngFor="let item of items">{{ item.productId }} x {{ item.quantity }}</div>
    </ng-container>
    <button (click)="clearCart()">Clear cart</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartPageComponent {
  readonly items$ = this.store.select(selectCartItems);

  constructor(private readonly store: Store) {}

  clearCart(): void {
    this.store.dispatch(cartCleared());
  }
}
Common MistakeStoring view-only flags like open modal state in every feature reducer leads to noisy global state.
Pro TipKeep root state for app-wide concerns and feature state for domain-specific data such as products, cart, and auth.

5. Selectors

Read Model

Selectors are how components read state without knowing reducer structure. They also centralize derivation logic, such as combining product entities with cart quantities to show a rich cart view model.

Memoization means the projector function only recomputes when an input selector changes reference. That keeps expensive calculations stable and avoids unnecessary UI work.

NgRx Code
export const selectCartState = createFeatureSelector<CartState>('cart');
export const selectProductsState = createFeatureSelector<ProductsState>('products');

export const selectCartItems = createSelector(
  selectCartState,
  state => state.items
);

export const selectProductEntities = createSelector(
  selectProductsState,
  state => state.entities
);

export const selectCartViewModels = createSelector(
  selectCartItems,
  selectProductEntities,
  (items, entities) => items.map(item => ({
    product: entities[item.productId],
    quantity: item.quantity,
    lineTotal: (entities[item.productId]?.price ?? 0) * item.quantity,
  }))
);

export const selectCartGrandTotal = createSelector(
  selectCartViewModels,
  items => items.reduce((sum, item) => sum + item.lineTotal, 0)
);

// The projector is the last function. Keep it pure and deterministic.
Component / Usage Code
@Component({
  selector: 'app-cart-summary',
  template: `
    <ng-container *ngIf="vm$ | async as vm">
      <div *ngFor="let item of vm.items">
        {{ item.product.name }} - {{ item.quantity }}
      </div>
      <strong>Total: {{ vm.total | currency:'USD' }}</strong>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartSummaryComponent {
  readonly vm$ = combineLatest({
    items: this.store.select(selectCartViewModels),
    total: this.store.select(selectCartGrandTotal),
  });

  constructor(private readonly store: Store) {}
}
Common MistakeDoing array mapping and filtering directly in components duplicates logic and defeats selector memoization.
Pro TipTest selector projector functions directly when you want fast, focused unit tests on derivation logic.

6. Effects

Async Side Effects

Effects react to actions and run side effects like HTTP calls, router navigation, and notifications outside components. This keeps container components thin and makes async orchestration reusable and testable.

Operator choice matters: switchMap cancels stale work, concatMap queues tasks, exhaustMap ignores repeats during an active request, and mergeMap runs in parallel.

OperatorUse it when
switchMapLatest search or filter request should win.
concatMapOrder matters, such as saving sequential order updates.
exhaustMapIgnore repeated login or checkout clicks until the first finishes.
mergeMapParallel work is acceptable, such as loading product details for several cards.
NgRx Code
@Injectable()
export class AuthEffects {
  readonly login$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loginSubmitted),
      // exhaustMap prevents double-submit races on login.
      exhaustMap(({ email, password }) =>
        this.authApi.login(email, password).pipe(
          map(user => loginSucceeded({ user })),
          catchError(() => of(loginFailed({ error: 'Invalid credentials' })))
        )
      )
    )
  );

  readonly loginSuccessNavigate$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(loginSucceeded),
        tap(() => this.router.navigate(['/products']))
      ),
    { dispatch: false }
  );

  constructor(
    private readonly actions$: Actions,
    private readonly authApi: AuthApiService,
    private readonly router: Router
  ) {}
}
Component / Usage Code
@Component({
  selector: 'app-login-form',
  template: `
    <form (ngSubmit)="submit()">
      <input [(ngModel)]="email" name="email" />
      <input [(ngModel)]="password" name="password" type="password" />
      <button>Sign in</button>
    </form>
    <p *ngIf="error$ | async as error">{{ error }}</p>
  `,
})
export class LoginFormComponent {
  email = '';
  password = '';
  readonly error$ = this.store.select(selectAuthError);

  constructor(private readonly store: Store) {}

  submit(): void {
    this.store.dispatch(loginSubmitted({ email: this.email, password: this.password }));
  }
}
Common MistakePutting HTTP subscriptions inside components creates duplicated loading, error, and retry behavior across screens.
Pro TipPick the flattening operator based on business behavior first, then code the effect around that rule.

7. HTTP Integration with Effects

Full CRUD

A mature NgRx feature usually follows the same pattern for HTTP work: request action, API call in effect, success or failure action, then reducer updates loading and error flags. Once the pattern is established, CRUD screens become consistent and easy to extend.

In products management, your store should expose items, loading state, and error state so components can render spinners and error banners declaratively.

NgRx Code
export const ProductsPageActions = createActionGroup({
  source: 'Products Page',
  events: {
    'Load Requested': emptyProps(),
    'Create Requested': props<{ product: ProductCreateDto }>(),
    'Update Requested': props<{ id: string; changes: Partial<Product> }>(),
    'Delete Requested': props<{ id: string }>(),
  },
});

export const ProductsApiActions = createActionGroup({
  source: 'Products API',
  events: {
    'Load Succeeded': props<{ products: Product[] }>(),
    'Create Succeeded': props<{ product: Product }>(),
    'Update Succeeded': props<{ product: Product }>(),
    'Delete Succeeded': props<{ id: string }>(),
    'Request Failed': props<{ error: string }>(),
  },
});

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

export const initialProductsState: ProductsState = {
  items: [],
  loading: false,
  error: null,
};

export const productsReducer = createReducer(
  initialProductsState,
  on(
    ProductsPageActions.loadRequested,
    ProductsPageActions.createRequested,
    ProductsPageActions.updateRequested,
    ProductsPageActions.deleteRequested,
    state => ({ ...state, loading: true, error: null })
  ),
  on(ProductsApiActions.loadSucceeded, (state, { products }) => ({ ...state, loading: false, items: products })),
  on(ProductsApiActions.createSucceeded, (state, { product }) => ({ ...state, loading: false, items: [...state.items, product] })),
  on(ProductsApiActions.updateSucceeded, (state, { product }) => ({
    ...state,
    loading: false,
    items: state.items.map(item => item.id === product.id ? product : item),
  })),
  on(ProductsApiActions.deleteSucceeded, (state, { id }) => ({
    ...state,
    loading: false,
    items: state.items.filter(item => item.id !== id),
  })),
  on(ProductsApiActions.requestFailed, (state, { error }) => ({ ...state, loading: false, error }))
);

@Injectable()
export class ProductsEffects {
  load$ = createEffect(() => this.actions$.pipe(
    ofType(ProductsPageActions.loadRequested),
    switchMap(() => this.api.getAll().pipe(
      map(products => ProductsApiActions.loadSucceeded({ products })),
      catchError(() => of(ProductsApiActions.requestFailed({ error: 'Could not load products' })))
    ))
  ));

  create$ = createEffect(() => this.actions$.pipe(
    ofType(ProductsPageActions.createRequested),
    concatMap(({ product }) => this.api.create(product).pipe(
      map(created => ProductsApiActions.createSucceeded({ product: created })),
      catchError(() => of(ProductsApiActions.requestFailed({ error: 'Create failed' })))
    ))
  ));

  update$ = createEffect(() => this.actions$.pipe(
    ofType(ProductsPageActions.updateRequested),
    concatMap(({ id, changes }) => this.api.update(id, changes).pipe(
      map(product => ProductsApiActions.updateSucceeded({ product })),
      catchError(() => of(ProductsApiActions.requestFailed({ error: 'Update failed' })))
    ))
  ));

  delete$ = createEffect(() => this.actions$.pipe(
    ofType(ProductsPageActions.deleteRequested),
    mergeMap(({ id }) => this.api.delete(id).pipe(
      map(() => ProductsApiActions.deleteSucceeded({ id })),
      catchError(() => of(ProductsApiActions.requestFailed({ error: 'Delete failed' })))
    ))
  ));

  constructor(private readonly actions$: Actions, private readonly api: ProductsApiService) {}
}
Component / Usage Code
@Component({
  selector: 'app-products-page',
  template: `
    <button (click)="reload()">Reload</button>
    <div *ngIf="loading$ | async" class="spinner">Loading...</div>
    <p *ngIf="error$ | async as error" class="error">{{ error }}</p>

    <app-product-table
      [products]="products$ | async"
      (create)="create($event)"
      (update)="update($event)"
      (delete)="remove($event)"
    />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductsPageComponent implements OnInit {
  readonly products$ = this.store.select(selectAllProducts);
  readonly loading$ = this.store.select(selectProductsLoading);
  readonly error$ = this.store.select(selectProductsError);

  constructor(private readonly store: Store) {}

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

  reload(): void { this.store.dispatch(ProductsPageActions.loadRequested()); }
  create(product: ProductCreateDto): void { this.store.dispatch(ProductsPageActions.createRequested({ product })); }
  update(event: { id: string; changes: Partial<Product> }): void { this.store.dispatch(ProductsPageActions.updateRequested(event)); }
  remove(id: string): void { this.store.dispatch(ProductsPageActions.deleteRequested({ id })); }
}
Common MistakeUsing one generic “Save Product” action for create and update makes reducer logic and analytics muddy.
Pro TipKeep a predictable request-success-failure trio for every async path so UI states stay uniform across the app.

8. Entity State with @ngrx/entity

Normalized Collections

Entity state gives you normalized storage for collections so updates stay fast and selectors become cleaner. Instead of manually managing arrays and lookup maps, you use an adapter that handles common immutable operations for you.

This is especially useful for products and orders because you frequently need “all items”, “one item by id”, and “count” from the same feature state.

NgRx Code
export interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

export interface ProductsState extends EntityState<Product> {
  loading: boolean;
  error: string | null;
}

export const productsAdapter = createEntityAdapter<Product>({
  selectId: product => product.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

export const initialState: ProductsState = productsAdapter.getInitialState({
  loading: false,
  error: null,
});

export const productsReducer = createReducer(
  initialState,
  on(ProductsApiActions.loadSucceeded, (state, { products }) =>
    productsAdapter.setAll(products, { ...state, loading: false })
  ),
  on(ProductsApiActions.createSucceeded, (state, { product }) =>
    productsAdapter.addOne(product, { ...state, loading: false })
  ),
  on(ProductsApiActions.updateSucceeded, (state, { product }) =>
    productsAdapter.updateOne({ id: product.id, changes: product }, { ...state, loading: false })
  ),
  on(ProductsApiActions.deleteSucceeded, (state, { id }) =>
    productsAdapter.removeOne(id, { ...state, loading: false })
  )
);

export const {
  selectAll,
  selectEntities,
  selectIds,
  selectTotal,
} = productsAdapter.getSelectors();
Component / Usage Code
const selectProductsFeature = createFeatureSelector<ProductsState>('products');

export const selectAllProducts = createSelector(selectProductsFeature, selectAll);
export const selectProductEntities = createSelector(selectProductsFeature, selectEntities);
export const selectProductsCount = createSelector(selectProductsFeature, selectTotal);

@Component({
  selector: 'app-admin-products-header',
  template: `
    <div>
      {{ total$ | async }} products loaded
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminProductsHeaderComponent {
  readonly total$ = this.store.select(selectProductsCount);

  constructor(private readonly store: Store) {}
}
Common MistakeKeeping both a full array and a separate entities map manually often leads to inconsistent duplicate state.
Pro TipUse entity adapters whenever the feature behaves like a collection with CRUD operations and lookup by id.

9. Component Store (@ngrx/component-store)

Local Reactive State

ComponentStore is ideal for state that is complex enough to benefit from NgRx patterns but not global enough for the app-wide store. Think product table pagination, filter panels, modal wizards, or local cart preview state.

You still get selectors, updaters, and effects, but scoped to a component or feature shell.

NgRx Code
interface ProductsTableState {
  page: number;
  pageSize: number;
  search: string;
  loading: boolean;
}

@Injectable()
export class ProductsTableStore extends ComponentStore<ProductsTableState> {
  constructor(private readonly api: ProductsApiService) {
    super({ page: 1, pageSize: 10, search: '', loading: false });
  }

  readonly page$ = this.select(state => state.page);
  readonly vm$ = this.select(state => state);

  readonly setSearch = this.updater((state, search: string) => ({
    ...state,
    search,
    page: 1,
  }));

  readonly nextPage = this.updater(state => ({
    ...state,
    page: state.page + 1,
  }));

  readonly loadPage = this.effect(trigger$ =>
    trigger$.pipe(
      tap(() => this.patchState({ loading: true })),
      switchMap(() =>
        this.api.getPaged(this.get().page, this.get().pageSize, this.get().search).pipe(
          tapResponse({
            next: () => this.patchState({ loading: false }),
            error: () => this.patchState({ loading: false }),
          })
        )
      )
    )
  );
}
Component / Usage Code
@Component({
  selector: 'app-products-table-shell',
  providers: [ProductsTableStore],
  template: `
    <input (input)="search($any($event.target).value)" placeholder="Search products" />
    <button (click)="next()">Next page</button>
    <div *ngIf="vm$ | async as vm">Page: {{ vm.page }} | Loading: {{ vm.loading }}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductsTableShellComponent {
  readonly vm$ = this.store.vm$;

  constructor(private readonly store: ProductsTableStore) {
    this.store.loadPage();
  }

  search(term: string): void {
    this.store.setSearch(term);
    this.store.loadPage();
  }

  next(): void {
    this.store.nextPage();
    this.store.loadPage();
  }
}
Common MistakePromoting every local interaction state into the global store makes global state huge and harder to understand.
Pro TipUse ComponentStore for rich local state that belongs to a page shell, but keep cross-page business data in the root store.

10. Router Store (@ngrx/router-store)

Route State

Router Store syncs Angular router state into NgRx so selectors can read route params, query params, and navigation info without injecting ActivatedRoute everywhere. This is useful when route state participates in effects or shared selectors.

For example, a product details effect can react to route changes and load the current product from the store pipeline.

NgRx Code
@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    StoreRouterConnectingModule.forRoot(),
  ],
})
export class AppModule {}

export interface AppState {
  router: RouterReducerState;
}

export const {
  selectRouteParams,
  selectQueryParams,
  selectRouteParam,
} = getRouterSelectors();

export const selectCurrentProductId = createSelector(
  selectRouteParam('id'),
  id => id
);

export const productDetailsRouteEntered = createAction('[Router] Product Details Entered');
export const navigateToCheckout = createAction('[Cart] Navigate To Checkout');

@Injectable()
export class RouterEffects {
  navigateToCheckout$ = createEffect(
    () => this.actions$.pipe(
      ofType(navigateToCheckout),
      tap(() => this.router.navigate(['/checkout']))
    ),
    { dispatch: false }
  );

  constructor(private readonly actions$: Actions, private readonly router: Router) {}
}
Component / Usage Code
@Component({
  selector: 'app-product-details-page',
  template: `
    <ng-container *ngIf="productId$ | async as productId">
      Current route product: {{ productId }}
    </ng-container>
    <button (click)="goCheckout()">Checkout</button>
  `,
})
export class ProductDetailsPageComponent {
  readonly productId$ = this.store.select(selectCurrentProductId);
  readonly queryParams$ = this.store.select(selectQueryParams);

  constructor(private readonly store: Store) {}

  goCheckout(): void {
    this.store.dispatch(navigateToCheckout());
  }
}
Common MistakeMixing route param parsing logic across many components creates duplicate code and inconsistent defaults.
Pro TipCentralize route-derived selectors so product, order, and filter pages all consume route state the same way.

11. NgRx Signals Store (@ngrx/signals)

Angular 16-17

Signals Store offers a more Angular-native way to model reactive state with signals and computed values. It feels lighter than the classic store and is especially attractive for local or feature-scoped state where full action streams may be unnecessary.

The classic store is still stronger for large event-driven architectures, advanced tooling, and strict global event tracing. Signals Store is simpler when you want direct methods and computed state.

NgRx Code
export const CartSignalStore = signalStore(
  { providedIn: 'root' },
  withState({
    items: [] as CartItem[],
    promoCode: '',
  }),
  withComputed(({ items }) => ({
    totalItems: computed(() => items().reduce((sum, item) => sum + item.quantity, 0)),
  })),
  withMethods((store) => ({
    add(productId: string) {
      patchState(store, state => {
        const existing = state.items.find(item => item.productId === productId);
        if (existing) {
          return {
            items: state.items.map(item =>
              item.productId === productId ? { ...item, quantity: item.quantity + 1 } : item
            ),
          };
        }
        return { items: [...state.items, { productId, quantity: 1 }] };
      });
    },
  })),
  withHooks({
    onInit() {
      // Good place for initial feature setup when store is created.
      console.log('CartSignalStore ready');
    },
  })
);

// Classic store is action-driven and centralized.
// Signals store is method-driven and signal-native.
Component / Usage Code
@Component({
  selector: 'app-signal-cart-button',
  template: `
    <button (click)="add()">Add to cart</button>
    <span>Items: {{ cartStore.totalItems() }}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SignalCartButtonComponent {
  readonly cartStore = inject(CartSignalStore);
  @Input({ required: true }) productId!: string;

  add(): void {
    this.cartStore.add(this.productId);
  }
}

// Compare this to classic NgRx where components dispatch actions and select observables.
// Signals Store gives synchronous signal reads and methods instead.
Common MistakeTreating Signals Store as a drop-in replacement for every classic NgRx use case ignores the benefits of explicit action streams.
Pro TipUse Signals Store where signal ergonomics matter most, and keep classic store for complex cross-feature workflows.

12. Devtools & Debugging

Observability

Devtools make NgRx teachable because you can inspect every action, diff state transitions, and time-travel through the app. This is one of the biggest practical reasons teams choose NgRx over ad hoc service state.

You can also restrict Devtools in production and configure history depth to avoid performance issues in large apps.

NgRx Code
@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    StoreDevtoolsModule.instrument({
      maxAge: 50,
      logOnly: environment.production,
      autoPause: true,
      trace: !environment.production,
      traceLimit: 25,
    }),
  ],
})
export class AppModule {}

// DevTools let you inspect:
// 1. Action log
// 2. Previous vs next state
// 3. Time-travel replay
// 4. Selector assumptions during debugging
Component / Usage Code
@Component({
  selector: 'app-debug-cart',
  template: `
    <button (click)="seed()">Seed cart</button>
    <button (click)="clear()">Clear</button>
  `,
})
export class DebugCartComponent {
  constructor(private readonly store: Store) {}

  seed(): void {
    this.store.dispatch(addToCart({ productId: 'p-101' }));
    this.store.dispatch(addToCart({ productId: 'p-102' }));
  }

  clear(): void {
    this.store.dispatch(cartCleared());
  }

  // Use Redux DevTools browser extension to inspect these actions in order.
}
Common MistakeLeaving verbose tracing always on in production-like environments can hurt performance and leak internals.
Pro TipUse DevTools during feature development to verify action semantics before the app grows too large.

13. Meta-Reducers

Reducer Middleware

Meta-reducers wrap reducers and let you apply cross-cutting behavior to every state transition. Common uses are logging, hydration from local storage, and resetting the app on logout.

Think of them as reducer middleware: they intercept state calculation before the final next state is returned.

NgRx Code
export function loggerMetaReducer(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
  return (state, action) => {
    const next = reducer(state, action);
    console.debug('[NgRx]', action.type, { prev: state, next });
    return next;
  };
}

export function hydrationMetaReducer(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
  return (state, action) => {
    if (action.type === '@ngrx/store/init') {
      const saved = localStorage.getItem('shop-state');
      return saved ? JSON.parse(saved) : reducer(state, action);
    }

    const nextState = reducer(state, action);
    localStorage.setItem('shop-state', JSON.stringify({ cart: nextState.cart }));
    return nextState;
  };
}

export function resetOnLogoutMetaReducer(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
  return (state, action) => {
    if (action.type === logoutSucceeded.type) {
      // Passing undefined forces reducers back to initial state.
      return reducer(undefined, { type: '@@INIT_AFTER_LOGOUT' });
    }
    return reducer(state, action);
  };
}

export const metaReducers: MetaReducer<AppState>[] = [
  loggerMetaReducer,
  hydrationMetaReducer,
  resetOnLogoutMetaReducer,
];
Component / Usage Code
@NgModule({
  imports: [
    StoreModule.forRoot(reducers, { metaReducers }),
  ],
})
export class AppModule {}

@Component({
  selector: 'app-account-menu',
  template: `<button (click)="logout()">Logout</button>`,
})
export class AccountMenuComponent {
  constructor(private readonly store: Store) {}

  logout(): void {
    // A logout action can trigger both auth cleanup and full app reset.
    this.store.dispatch(logoutSucceeded());
  }
}
Common MistakePutting feature-specific hacks in a meta-reducer spreads hidden behavior across the whole app.
Pro TipUse meta-reducers only for truly cross-cutting concerns such as hydration, logging, or app-wide resets.

14. Action Hygiene & Best Practices

Maintainability

Action quality determines how understandable your store becomes over time. Good action hygiene means one action has one clear purpose, features do not depend on each other’s internal actions, and reusable patterns stay explicit instead of generic.

This is the difference between a store that scales and a store that turns into event soup.

NgRx Code
// Good: one action, one business event.
export const checkoutSubmitted = createAction(
  '[Checkout Page] Checkout Submitted',
  props<{ paymentMethodId: string }>()
);

// Avoid coupling: cart feature should not depend on private admin actions.
export const orderPlacedSucceeded = createAction(
  '[Orders API] Order Placed Succeeded',
  props<{ order: Order }>()
);

// Reusable action factory when shape is repeated intentionally.
function createRequestStateActions(source: string) {
  return createActionGroup({
    source,
    events: {
      'Started': emptyProps(),
      'Succeeded': emptyProps(),
      'Failed': props<{ error: string }>(),
    },
  });
}

export const CartCouponActions = createRequestStateActions('Cart Coupon API');
Component / Usage Code
@Injectable({ providedIn: 'root' })
export class CheckoutFacade {
  readonly processing$ = this.store.select(selectCheckoutProcessing);

  constructor(private readonly store: Store) {}

  submit(paymentMethodId: string): void {
    // Facade prevents components from knowing too many action details.
    this.store.dispatch(checkoutSubmitted({ paymentMethodId }));
  }
}

@Component({
  selector: 'app-checkout-button',
  template: `<button (click)="submit()" [disabled]="processing$ | async">Place order</button>`,
})
export class CheckoutButtonComponent {
  readonly processing$ = this.facade.processing$;
  constructor(private readonly facade: CheckoutFacade) {}
  submit(): void { this.facade.submit('pm_001'); }
}
Common MistakeCreating catch-all actions like “Update State” makes debugging impossible because intent is lost.
Pro TipReview action names in DevTools as if they were an event log for your business, because that is exactly what they become.

15. Facade Pattern

Abstraction Layer

A facade is a service that hides NgRx details from components and exposes a small API of observables and methods. This keeps templates clean, lowers coupling, and makes store refactors easier because components do not care whether data comes from Store, ComponentStore, or Signals Store.

It also simplifies testing, because component tests can mock one facade instead of full store behavior.

NgRx Code
@Injectable({ providedIn: 'root' })
export class CartFacade {
  readonly items$ = this.store.select(selectCartViewModels);
  readonly total$ = this.store.select(selectCartGrandTotal);
  readonly count$ = this.store.select(selectCartItemCount);

  constructor(private readonly store: Store) {}

  load(): void {
    this.store.dispatch(cartLoadedRequested());
  }

  add(productId: string): void {
    this.store.dispatch(addToCart({ productId }));
  }

  remove(productId: string): void {
    this.store.dispatch(removeFromCart({ productId }));
  }

  clear(): void {
    this.store.dispatch(cartCleared());
  }
}
Component / Usage Code
@Component({
  selector: 'app-cart-drawer',
  template: `
    <div *ngFor="let item of items$ | async">
      {{ item.product.name }}
      <button (click)="remove(item.product.id)">Remove</button>
    </div>
    <strong>{{ total$ | async | currency:'USD' }}</strong>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartDrawerComponent implements OnInit {
  readonly items$ = this.facade.items$;
  readonly total$ = this.facade.total$;

  constructor(private readonly facade: CartFacade) {}

  ngOnInit(): void {
    this.facade.load();
  }

  remove(productId: string): void {
    this.facade.remove(productId);
  }
}
Common MistakeTurning a facade into a giant god service with every selector and action from the app defeats the purpose of feature boundaries.
Pro TipKeep facades feature-scoped, such as CartFacade or ProductsFacade, with only the API that consumers truly need.

16. Optimistic vs Pessimistic Updates

UX Strategy

Pessimistic updates wait for the server before changing store state, which is safer for high-risk operations like payment or inventory-sensitive actions. Optimistic updates change the UI immediately, then roll back on failure, which feels faster for low-risk actions like cart quantity tweaks or wishlist toggles.

The right choice depends on business cost of being wrong, not developer preference.

NgRx Code
// PESSIMISTIC: wait for API, then commit.
export const cartQuantityChangeRequested = createAction(
  '[Cart Page] Quantity Change Requested',
  props<{ productId: string; quantity: number }>()
);
export const cartQuantityChangeSucceeded = createAction(
  '[Cart API] Quantity Change Succeeded',
  props<{ productId: string; quantity: number }>()
);

// OPTIMISTIC: update immediately, rollback if API fails.
export const cartQuantityChangedOptimistic = createAction(
  '[Cart Page] Quantity Changed Optimistic',
  props<{ productId: string; quantity: number; previousQuantity: number }>()
);
export const cartQuantityChangeRollback = createAction(
  '[Cart API] Quantity Change Rollback',
  props<{ productId: string; previousQuantity: number; error: string }>()
);

@Injectable()
export class CartEffects {
  pessimistic$ = createEffect(() => this.actions$.pipe(
    ofType(cartQuantityChangeRequested),
    concatMap(({ productId, quantity }) => this.api.updateQuantity(productId, quantity).pipe(
      map(() => cartQuantityChangeSucceeded({ productId, quantity })),
      catchError(() => of(cartSyncFailed({ error: 'Could not update cart' })))
    ))
  ));

  optimistic$ = createEffect(() => this.actions$.pipe(
    ofType(cartQuantityChangedOptimistic),
    mergeMap(({ productId, quantity, previousQuantity }) => this.api.updateQuantity(productId, quantity).pipe(
      map(() => cartQuantitySynced({ productId, quantity })),
      catchError(() => of(cartQuantityChangeRollback({ productId, previousQuantity, error: 'Server rejected update' })))
    ))
  ));
}

export const cartReducer = createReducer(
  initialCartState,
  on(cartQuantityChangeSucceeded, (state, { productId, quantity }) => ({
    ...state,
    items: state.items.map(item => item.productId === productId ? { ...item, quantity } : item),
  })),
  on(cartQuantityChangedOptimistic, (state, { productId, quantity }) => ({
    ...state,
    items: state.items.map(item => item.productId === productId ? { ...item, quantity } : item),
  })),
  on(cartQuantityChangeRollback, (state, { productId, previousQuantity }) => ({
    ...state,
    items: state.items.map(item => item.productId === productId ? { ...item, quantity: previousQuantity } : item),
  }))
);
Component / Usage Code
@Component({
  selector: 'app-cart-line-item',
  template: `
    <button (click)="pessimisticUpdate()">Safe update</button>
    <button (click)="optimisticUpdate()">Fast update</button>
  `,
})
export class CartLineItemComponent {
  @Input({ required: true }) item!: CartItem;
  constructor(private readonly store: Store) {}

  pessimisticUpdate(): void {
    this.store.dispatch(cartQuantityChangeRequested({
      productId: this.item.productId,
      quantity: this.item.quantity + 1,
    }));
  }

  optimisticUpdate(): void {
    this.store.dispatch(cartQuantityChangedOptimistic({
      productId: this.item.productId,
      quantity: this.item.quantity + 1,
      previousQuantity: this.item.quantity,
    }));
  }
}
Common MistakeUsing optimistic updates for irreversible actions like payment capture can create dangerous false confirmation in the UI.
Pro TipUse optimistic updates where rollback is cheap and visible, and pessimistic updates where correctness is more important than speed.

17. Performance Optimization

Scale

NgRx performs well when selectors stay pure, change detection is constrained, and components subscribe only to what they need. Angular 16-17 gives you more options here with selectSignal() and signal-friendly rendering patterns.

The biggest wins usually come from selector composition, OnPush components, and avoiding large template-level recomputation.

NgRx Code
export const selectVisibleProducts = createSelector(
  selectAllProducts,
  selectCategoryFilter,
  selectSearchTerm,
  (products, category, search) =>
    products.filter(product => {
      const matchesCategory = !category || product.category === category;
      const matchesSearch = !search || product.name.toLowerCase().includes(search.toLowerCase());
      return matchesCategory && matchesSearch;
    })
);

@Injectable({ providedIn: 'root' })
export class ProductsQuery {
  constructor(private readonly store: Store) {}

  // selectSignal gives synchronous signal reads in Angular 16-17.
  readonly visibleProducts = this.store.selectSignal(selectVisibleProducts);
}
Component / Usage Code
@Component({
  selector: 'app-products-grid',
  template: `
    <ng-container *ngIf="products$ | async as products">
      <app-product-card *ngFor="let product of products; trackBy: trackById" [product]="product" />
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductsGridComponent {
  readonly products$ = this.store.select(selectVisibleProducts);
  readonly productsSignal = this.store.selectSignal(selectVisibleProducts);

  constructor(private readonly store: Store) {}

  trackById = (_: number, product: Product) => product.id;
}

// ngrxLet / let-style directives reduce repeated async pipes in large templates.
// OnPush plus memoized selectors is the default performance baseline.
Common MistakeCreating new arrays or objects inside many small selectors can cause avoidable recomputation chains.
Pro TipBuild selectors in layers and expose stable derived view models so templates stay simple and cheap.

18. Testing NgRx

Confidence

NgRx is testable because reducers are pure, selectors are deterministic, and effects can be driven by mock action streams. You can test each layer in isolation instead of spinning up a full Angular app.

For component tests, MockStore and selector overrides let you focus on rendering and dispatched actions without real reducers.

NgRx Code
describe('cartReducer', () => {
  it('increments quantity when item already exists', () => {
    const state: CartState = { items: [{ productId: 'p1', quantity: 1 }], couponCode: null };
    const next = cartReducer(state, addToCart({ productId: 'p1' }));
    expect(next.items[0].quantity).toBe(2);
  });
});

describe('selectCartGrandTotal', () => {
  it('computes total with projector', () => {
    const result = selectCartGrandTotal.projector([
      { lineTotal: 100 },
      { lineTotal: 50 },
    ] as any);

    expect(result).toBe(150);
  });
});

describe('ProductsEffects', () => {
  let actions$: Observable<Action>;
  let effects: ProductsEffects;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        ProductsEffects,
        provideMockActions(() => actions$),
        { provide: ProductsApiService, useValue: { getAll: () => cold('--a', { a: [{ id: 'p1' }] }) } },
      ],
    });

    effects = TestBed.inject(ProductsEffects);
  });

  it('loads products', () => {
    actions$ = hot('-a', { a: ProductsPageActions.loadRequested() });
    expect(effects.load$).toBeObservable(
      cold('---b', { b: ProductsApiActions.loadSucceeded({ products: [{ id: 'p1' } as Product] }) })
    );
  });
});
Component / Usage Code
describe('CartSummaryComponent', () => {
  let store: MockStore;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [CartSummaryComponent],
      providers: [provideMockStore()],
    }).compileComponents();

    store = TestBed.inject(MockStore);
    store.overrideSelector(selectCartGrandTotal, 250);
    store.overrideSelector(selectCartViewModels, [{ product: { name: 'Laptop' }, quantity: 1, lineTotal: 250 }] as any);
  });

  afterEach(() => {
    // Reset memoized selectors between tests when needed.
    selectCartGrandTotal.release();
    selectCartViewModels.release();
  });

  it('renders total from store', () => {
    const fixture = TestBed.createComponent(CartSummaryComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('250');
  });
});
Common MistakeOverusing integration-style tests for reducers and selectors makes test suites slower than necessary.
Pro TipTest reducers and selectors as pure units first, then add targeted effect and component tests around the important workflows.

NgRx Quick Reference Cheat Sheet

createXxx functions

  • createAction() — define a typed event.
  • createActionGroup() — group related events by source.
  • createReducer() — define immutable state transitions.
  • on() — map one or more actions to reducer logic.
  • createSelector() — derive memoized state.
  • createFeatureSelector() — select one feature slice.
  • createFeature() — feature registration with generated selectors.
  • createEffect() — respond to actions with side effects.
  • createEntityAdapter() — normalized collection helpers.
  • signalStore() — build signal-driven store APIs.

Common RxJS in Effects

  • ofType() — filter the action stream.
  • switchMap() — cancel stale requests, keep latest.
  • concatMap() — process requests in order.
  • exhaustMap() — ignore repeats during active work.
  • mergeMap() — run tasks in parallel.
  • map() — transform API response into action.
  • catchError() — convert errors into failure actions.
  • tap() — non-dispatch side effects like navigation.

NgRx packages

  • npm i @ngrx/store — core store.
  • npm i @ngrx/effects — async side effects.
  • npm i @ngrx/store-devtools — debugging support.
  • npm i @ngrx/entity — entity collection helpers.
  • npm i @ngrx/router-store — router state integration.
  • npm i @ngrx/component-store — local reactive state.
  • npm i @ngrx/signals — signals-based state primitives.

Selector composition

  • Feature selector → primitive slice selector → derived selector.
  • Keep projectors pure and deterministic.
  • Compose selectors instead of nesting store subscriptions.
  • Return stable references whenever possible.
  • Prefer derived view models over component-side mapping.