Don't let your Effects die (catchSwitchMapError operator)

This post assumes you have a good understanding of angular, ngrx, Effects and rxjs already. If thats not the case, it might not be very interesting for you :)

A common use for ngrx Effects is integrating calls to a REST API into the redux pattern. Typically the workflow would look something like this:

1
2
3
4
5
6
7
8
requestBananaEffect = createEffect(() =>
this.actions.pipe(
ofType(BananaActions.RequestBanana),
switchMap(() => this.api.getBanana()),
map((result) => BananaActions.ReceiveBanana({ result })),
catchError((err) => of(BananaActions.OutOfBananas({ err })))
)
);
  1. Dispatch an Action BananaActions.RequestBanana
  2. Listen to the Action in an Effect using ofType
  3. Switch to the api call using switchMap
  4. Dispatch an Action BananaActions.ReceiveBanana with the result of the api call (hopefully a banana)
  5. In case something goes wrong (i.e. the server is out of bananas) handle the error using catchError and return an Observable with an appropriate Action

This implementation bears a problem though: While BananaActions.OutOfBananas will be dispatched if the api call throws an error, the Effect will also complete as we return a new Observable using of(). This is not optimal as an Effect should not complete during runtime. It is supposed to be a hot Observable. If an Effect completes it stops handling new Action emissions and is basically useless afterwards.

To prevent this behavior we have to adapt the catchError statement slightly:

1
catchError((error, source) => source.pipe(startWith(BananaActions.OutOfBananas({ error }))));

Instead of returning a new Observable we use the second parameter of catchError which is a reference to the original Observable. Using startWith we return our desired error Action and thus “revive” the Effect. (thanks to cartant on GitHub for this tip).

Finally we can encapsulate this into an own custom operator:

1
2
3
4
export const catchSwitchMapError =
(errorAction: (error: any) => any) =>
<T>(source: Observable<T>) =>
source.pipe(catchError((error, innerSource) => innerSource.pipe(startWith(errorAction(error)))));

With this operator the original code now looks only slightly different, but we magically avoid the problem of the “dying” Effect!

1
2
3
4
5
6
7
8
requestBananaEffect = createEffect(() =>
this.actions.pipe(
ofType(BananaActions.RequestBanana),
switchMap(() => this.api.getBanana()),
map((result) => BananaActions.ReceiveBanana({ result })),
catchSwitchMapError((err) => BananaActions.OutOfBananas({ err }))
)
);