Skip to the solution
MediumFrontend11 min read

What is the role of NgRx Effects, and why do we separate side effects from reducers?

840 views

The problem

Question presented to candidate: "In our NgRx app, a developer put the HttpClient call for loading users directly in the component and dispatches the result, and another suggested calling the API inside the reducer. Explain what NgRx Effects are for, why side effects are kept out of reducers, and how you would write the load-users flow."

What a strong answer should cover:

  • An effect is an Observable pipeline that listens to the action stream, performs side effects (HTTP, navigation, storage, timers), and usually maps the outcome to new actions that are dispatched back to the store.
  • Reducers must be pure and synchronous: same input, same output, no I/O. That keeps them predictable, replayable (DevTools time travel), and trivially testable; an HTTP call cannot return the next state synchronously anyway.
  • Components only dispatch events ("[Users Page] Opened") and read selectors; they do not know where data comes from.
  • The canonical shape: ofType(opened) then a flattening operator (switchMap, exhaustMap, concatMap, mergeMap, chosen deliberately), the API call, map to a success action, and catchError inside the inner pipe mapping to a failure action.
  • Variants: functional effects (createEffect(fn, { functional: true })), non-dispatching effects ({ dispatch: false }), and the default error handler that resubscribes an effect after an uncaught error (up to 10 times).
  • Testing: provideMockActions and marble or plain subscription tests.

Clarifying questions expected:

  • "What should happen if the user triggers the load again while one is in flight?"
  • "Should a failure be shown to the user, retried, or both?"

Code / implementation expected: Yes β€” actions, a reducer handling success and failure, and an effect with the correct operator and error handling.

  • ngrx
  • architecture

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read Β· 10 sections

Read the code

AngularA functional effect with exhaustMap, a failure action and a non-dispatching toast effect (NgRx 22)
import { ApplicationConfig, Component, Injectable, inject, signal } from "@angular/core";
import { Actions, createEffect, ofType, provideEffects } from "@ngrx/effects";
import { ActionReducer, Store, createActionGroup, createFeature, createReducer, emptyProps, on, props, provideStore } from "@ngrx/store";
import { Observable, catchError, delay, exhaustMap, map, of, tap, throwError } from "rxjs";

// ── Actions: the component only ever says WHAT happened. ───────────────────
export const UsersActions = createActionGroup({
  source: "Users Page",
  events: {
    Opened: emptyProps(),
    "Load Success": props<{ users: string[] }>(),
    "Load Failure": props<{ error: string }>(),
  },
});

// ── A fake API (stands in for HttpClient). Toggle `failNext` to simulate a 500. ──
@Injectable({ providedIn: "root" })
export class UsersApi {
  failNext = false;
  calls = 0;
  getAll(): Observable<string[]> {
    this.calls++;
    return this.failNext ? throwError(() => new Error("500 Server Error")) : of(["Ann", "Bob"]).pipe(delay(100));
  }
}

// ── Reducer: pure, synchronous, no HTTP. It just records outcomes. ─────────
interface UsersState {
  users: string[];
  loading: boolean;
  error: string | null;
}
export const usersFeature = createFeature({
  name: "users",
  reducer: createReducer<UsersState>(
    { users: [], loading: false, error: null },
    on(UsersActions.opened, (s) => ({ ...s, loading: true, error: null })),
    on(UsersActions.loadSuccess, (s, { users }) => ({ ...s, users, loading: false })),
    on(UsersActions.loadFailure, (s, { error }) => ({ ...s, error, loading: false })),
  ),
});

// ── Effects: listen for actions, do the async work, dispatch the result. ──
export const loadUsers = createEffect(
  (actions$ = inject(Actions), api = inject(UsersApi)) =>
    actions$.pipe(
      ofType(UsersActions.opened),
      exhaustMap(() =>
        api.getAll().pipe(
          map((users) => UsersActions.loadSuccess({ users })),
          // catchError INSIDE the inner stream: the effect itself stays alive.
          catchError((e: Error) => of(UsersActions.loadFailure({ error: e.message }))),
        ),
      ),
    ),
  { functional: true },
);

// A non-dispatching effect: a side effect with no resulting action.
export const notifyOnFailure = createEffect(
  (actions$ = inject(Actions)) =>
    actions$.pipe(
      ofType(UsersActions.loadFailure),
      tap(({ error }) => toasts.update((t) => [...t, "toast: " + error])),
    ),
  { functional: true, dispatch: false },
);

export const toasts = signal<string[]>([]);
export const actionLog = signal<string[]>([]);
function logger(reducer: ActionReducer<any>): ActionReducer<any> {
  return (state, action) => {
    actionLog.update((l) => [...l, action.type]);
    return reducer(state, action);
  };
}

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore({ [usersFeature.name]: usersFeature.reducer }, { metaReducers: [logger] }),
    provideEffects({ loadUsers, notifyOnFailure }),
  ],
};

@Component({
  selector: "app-root",
  template: `
    <label><input type="checkbox" (change)="api.failNext = $any($event.target).checked" /> make the API fail</label>
    <button (click)="store.dispatch(opened())">Open users page</button>
    <p>loading={{ loading() }} users={{ users().join(", ") || "none" }} error={{ error() ?? "none" }}</p>
    <p>{{ toasts().join(" | ") }}</p>
    <ol>
      @for (type of log(); track $index) {
        <li>{{ type }}</li>
      }
    </ol>
  `,
})
export class AppComponent {
  store = inject(Store);
  api = inject(UsersApi);
  opened = UsersActions.opened;
  users = this.store.selectSignal(usersFeature.selectUsers);
  loading = this.store.selectSignal(usersFeature.selectLoading);
  error = this.store.selectSignal(usersFeature.selectError);
  toasts = toasts;
  log = actionLog;
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 30 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

What is a directive and what are the types?

Continue