skip to content

In NgRx 22, why does a login effect stop reacting after one failed login when catchError sits on the outer actions$ pipe, and what does NgRx's default error handler do?

level: seniorimportance: should knowfreq 52%

answer

  1. an observable ends once
  2. a fallback that completes
  3. the handler sees errors, not completions
  4. resubscribe, with a budget
  5. catch on the inner request

basics

~20 s

An outer catchError replaces the whole effect stream with its fallback, which emits loginFailed and completes, so the effect is finished. NgRx's default handler only resubscribes after uncaught errors, up to 10 times; catch on the inner request instead.

solid answer

~40 s

An NgRx effect is one observable, and an observable ends at its first error or completion. When `catchError(() => of(AuthActions.loginFailed()))` sits after `exhaustMap` on the outer pipe, the failed request's error travels up, `catchError` swaps the entire effect for `of(loginFailed)`, which emits once and completes, and the effect never hears another `loginSubmitted`. NgRx's safety net does not notice: with the default `useEffectsErrorHandler: true`, the default handler reports an *uncaught error* to Angular's `ErrorHandler` and resubscribes the effect, up to 10 times, but a completion is not an error. Without any `catchError`, that net keeps the effect alive yet dispatches no `loginFailed`, so the spinner keeps spinning. The fix is `catchError` on the request observable inside `exhaustMap`, returning `of(loginFailed(...))`: only the inner stream ends and the outer effect keeps listening.

code

ts · 24 lines
ts
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { AuthApi } from './auth-api.service';
import { AuthActions } from './auth.actions';

export const login = createEffect(
  (actions$ = inject(Actions), authApi = inject(AuthApi)) => {
    return actions$.pipe(
      ofType(AuthActions.loginSubmitted),
      exhaustMap(({ credentials }) =>
        authApi.login(credentials).pipe(
          map((user) => AuthActions.loginSucceeded({ user })),
          // Inside: only this request ends; the effect keeps listening.
          catchError((error: { message: string }) =>
            of(AuthActions.loginFailed({ message: error.message }))
          )
        )
      )
      // A catchError placed here would complete the whole effect.
    );
  },
  { functional: true }
);

go deeper

for a junior

Recall where catchError goes in an effect: on the request inside the flattening operator, returning of(failureAction).

for a middle

Explain that an effect is one observable ended by an error or completion, and walk through why an outer catchError completes it after the first failure.

for a senior

Describe the default effects error handler precisely: reports to ErrorHandler, resubscribes up to 10 errors, does not replay the action or dispatch a failure, and is blind to completions.

for a principal

Decide the app-wide policy: whether to keep the resubscription net, replace it through EFFECTS_ERROR_HANDLER with retryable-only rules, or turn it off per effect once errors are handled.

## The rule underneath An **observable** delivers values until it either **errors** or **completes**; after either, it delivers nothing more. An NgRx **effect** is a single observable built on the `Actions` stream, so anything that ends that observable ends the effect for the rest of the session. Keeping an effect alive after a failed request is about making sure the failure ends only the *request*, not the effect. ## Three placements for the login effect The flight-search app's login effect listens for `AuthActions.loginSubmitted`, calls the auth API inside `exhaustMap`, and should answer with `loginSucceeded` or `loginFailed`. | Placement | First failed login | Later logins | |---|---|---| | `catchError` **inside** `exhaustMap`, on the request | `loginFailed` dispatched | Handled normally | | `catchError` **after** `exhaustMap`, on the outer pipe | `loginFailed` dispatched | Ignored: the effect has completed | | **No** `catchError` | Nothing dispatched; error reported | Handled again after NgRx resubscribes | ### Outer `catchError` 1. The auth request errors. 2. The error propagates out of `exhaustMap` into the outer pipe. 3. `catchError` replaces the **whole effect stream** with its fallback, `of(loginFailed())`. 4. `of(...)` emits one action and **completes**. The effect is over; later `loginSubmitted` actions reach nobody. The first failure looks correct in the UI, which is why this bug survives code review and manual testing: only the *second* login attempt reveals it. ### No `catchError` at all `createEffect` defaults to `useEffectsErrorHandler: true`. NgRx then wraps the effect in its **default effects error handler**, which: - reports the error to Angular's `ErrorHandler`; - **resubscribes** to the effect's source, so it listens to future actions again; - does this for up to **10** errors over the effect's lifetime (`MAX_NUMBER_OF_RETRY_ATTEMPTS`); the budget does not reset after successful logins, and the next error after it is spent ends the effect. Resubscribing does **not** replay the failed `loginSubmitted`: the `Actions` stream keeps no history. And because no `loginFailed` is dispatched, a `loggingIn: true` flag set by the reducer is left in place. The NgRx docs call this behaviour a safety net for errors you missed, not a way to handle them. They also warn that resubscribing re-runs operators such as `startWith` placed in the effect's pipe. ### Why the safety net misses the outer `catchError` The default handler is itself built on catching **errors**. An outer `catchError` in your code has already turned the error into a normal completion, so the handler has nothing to catch and nothing to resubscribe. ## Recognising a dead effect A completed effect fails silently, so learn its symptoms: - The first failure behaves correctly; the **second** attempt does nothing at all. - The browser's network panel shows no request for the second attempt. - The Redux DevTools action log shows `loginSubmitted` dispatched, and reduced, with no `loginSucceeded` or `loginFailed` after it. - With no `catchError`, Angular's `ErrorHandler` logs the error each time, yet the UI stays in its loading state because no failure action exists. ## The correct shape - Put `catchError` on the **request observable inside the flattening operator** and return an observable of a failure action: `catchError((error) => of(AuthActions.loginFailed({ message: error.message })))`. Only the inner observable ends; `exhaustMap` keeps listening on the outer stream. - Returning `EMPTY` there also keeps the effect alive, but dispatches nothing, so the UI never learns the login failed. - `mapResponse({ next, error })` from `@ngrx/operators` packages the inner `map` plus `catchError` pair. - Once every request error is handled inside, you may set `{ useEffectsErrorHandler: false }`, as the NgRx docs show for a login effect. With that option an unexpected error ends the effect for good. - To change the policy app-wide, for example to resubscribe only on retryable errors or change the limit, provide your own function for the `EFFECTS_ERROR_HANDLER` injection token. ## What to say in an interview - An effect is one observable; an error or a completion ends it. - Outer `catchError` completes the effect after its fallback, and NgRx does not resubscribe after a completion. - The default handler reports uncaught errors and resubscribes up to 10 times, but dispatches no failure action. - Catch on the inner request and map the error to a failure action.

  • How would you make NgRx resubscribe effects only on network errors, across the whole app?
    Provide a function for the `EFFECTS_ERROR_HANDLER` token. It receives each effect's observable and Angular's `ErrorHandler`, and returns the observable NgRx subscribes to, so it can resubscribe on retryable errors, report the rest and let them end the effect. It replaces the default 10-attempt handler for every effect that keeps `useEffectsErrorHandler: true`.
  • Why can the default resubscription surprise you in an effect whose pipe starts with startWith?
    Resubscribing runs the effect's pipe again from the top, so `startWith` emits its initial value again and that value is dispatched as an action a second time. The NgRx docs name this case. Handling errors inside the request, or setting `useEffectsErrorHandler: false`, avoids it.

saying these in an interview costs you the question

  • The default effects error handler resubscribes after an outer catchError completes the effect.
  • NgRx's resubscription re-sends the failed login request automatically.
  • catchError returning EMPTY inside the request tells the UI the login failed.
  • NgRx's resubscription budget resets after every successful action.
  • dispatch: false is the option that disables resubscription after an error.