Selectors and Effects — Derived State and Side Effects

📋 Table of Contents
  1. Selectors
  2. Effects
  3. Common Mistakes

NgRx Selectors derive data from the store — filtering, sorting, and computing values without adding redundant data to the store. They are memoised: if inputs have not changed, the selector returns the cached result rather than recomputing. NgRx Effects handle side effects — primarily HTTP requests — by listening to the actions stream, calling services, and dispatching new actions for success and failure. Effects keep reducers pure (no HTTP calls in reducers) and components thin (no HTTP calls in components).

Selectors

import { createSelector, createFeatureSelector } from '@ngrx/store';

// ── Feature selector — root entry point ───────────────────────────────────
const selectPostsState = createFeatureSelector<PostsState>('posts');
// OR use the auto-generated one: postsFeature.selectPostsState

// ── Composed selectors — derived data ─────────────────────────────────────
export const selectAllPosts = createSelector(
  postsFeature.selectPosts,
  posts => posts
);

export const selectPublishedPosts = createSelector(
  postsFeature.selectPosts,
  posts => posts.filter(p => p.isPublished)
);

// ── Parameterised selectors (selector factories) ──────────────────────────
export const selectPostById = (id: number) => createSelector(
  postsFeature.selectPosts,
  posts => posts.find(p => p.id === id)
);

// ── Combining multiple feature selectors ──────────────────────────────────
export const selectDashboardData = createSelector(
  postsFeature.selectPosts,
  postsFeature.selectTotal,
  postsFeature.selectLoading,
  (posts, total, loading) => ({ posts, total, loading })
);

// ── Using selectors in components ─────────────────────────────────────────
@Component({ standalone: true, template: `
  @if (loading$ | async) { <app-spinner /> }
  @for (post of posts$ | async; track post?.id) {
    <app-post-card [post]="post!" />
  }
` })
export class PostListComponent implements OnInit {
  private store = inject(Store);

  posts$   = this.store.select(selectPublishedPosts);
  loading$ = this.store.select(postsFeature.selectLoading);

  ngOnInit() {
    this.store.dispatch(PostsActions.loadPosts({ page: 1, size: 10 }));
  }
}

Effects

import { createEffect, Actions, ofType } from '@ngrx/effects';
import { inject } from '@angular/core';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

// ── Effects class ─────────────────────────────────────────────────────────
@Injectable()
export class PostsEffects {
  private actions$ = inject(Actions);
  private api      = inject(PostsApiService);
  private router   = inject(Router);

  // ── Load posts effect ─────────────────────────────────────────────────
  loadPosts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PostsActions.loadPosts),        // only handle this action
      switchMap(({ page, size }) =>          // cancel previous on new dispatch
        this.api.getPublished(page, size).pipe(
          map(result => PostsActions.loadPostsSuccess({
            posts: result.items,
            total: result.total,
          })),
          catchError(err =>
            of(PostsActions.loadPostsFailure({ error: err.message }))
          ),
        )
      )
    )
  );

  // ── Delete post effect ────────────────────────────────────────────────
  deletePost$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PostsActions.deletePost),
      switchMap(({ id }) =>
        this.api.delete(id).pipe(
          map(() => PostsActions.deletePostSuccess({ id })),
          catchError(err =>
            of(PostsActions.deletePostFailure({ error: err.message }))
          )
        )
      )
    )
  );

  // ── Navigate after create ─────────────────────────────────────────────
  navigateAfterCreate$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PostsActions.createPostSuccess),
      tap(({ post }) => this.router.navigate(['/posts', post.slug]))
    ),
    { dispatch: false }   // this effect does not dispatch another action
  );
}

// ── Register effects ───────────────────────────────────────────────────────
// provideEffects(PostsEffects) in app.config.ts or route providers
Note: Effects must handle errors inside the inner Observable (the this.api.*() call), not at the outer level. If the catchError is outside the switchMap, an error terminates the entire actions stream — no more loadPosts actions will ever be handled. By placing catchError inside the switchMap callback, errors only terminate the inner HTTP Observable and return a failure action — the outer stream continues listening for future loadPosts dispatches.
Tip: NgRx Selectors are memoised with reference equality. A selector only recomputes when the input slice of state changes by reference. This means selectors are very cheap to call — components can select from the store multiple times per render without performance concerns. The memoisation is what makes NgRx practical for complex applications: even with 100+ components selecting from the same store, each selector only recomputes when its specific state slice changes.
Warning: Effects that dispatch actions must not create infinite loops. If effect A dispatches action B, and effect B dispatches action A, the two effects call each other indefinitely. Design action/effect chains carefully — typically: user action → load effect → success/failure actions. The store’s action log in Redux DevTools makes such loops immediately visible. Use { dispatch: false } on effects that only perform side effects (navigation, toasts) without dispatching another action.

Common Mistakes

Mistake 1 — catchError outside switchMap (terminates the effect permanently after first error)

❌ Wrong — actions$.pipe(ofType(...), switchMap(...), catchError(...)); first HTTP error kills the effect forever.

✅ Correct — catchError must be inside switchMap(action => api.call().pipe(catchError(...))).

Mistake 2 — Forgetting dispatch: false for navigation/toast effects (TypeError: actions must be objects)

❌ Wrong — navigation effect returns void; NgRx tries to dispatch void as an action and throws.

✅ Correct — add { dispatch: false } to effects that do not produce a new action to dispatch.

🧠 Test Yourself

A selectPublishedPosts selector derives filtered posts from the store. It is used by 5 components. How many times does the filter logic run when an unrelated updateUserProfile action is dispatched?