NgRx Workshop: Part 5 - Effects

NgRx Workshop: Part 5 - Effects

  • Processes that run in the background
  • Connect your app to the outside world
  • Often used to talk to services
  • Written entirely using RxJS streams

Notes

  • Try to keep effect close to the reducer and group them in classes as it seems convenient
  • For effects, it's okay to split them into separate effects files, one for each API service. But it's not a mandate
  • Is still possible to use guards and resolver, just dispatch an action when it is done
  • It is recommended to not use resolvers since we can dispatch the actions using effects
  • Put the books-api.effects file in the same level as books.module.ts, so that the bootstrapping is done at this level and effects are loaded and running if and only if the books page is loaded. If we were to put the effects in the shared global states, the effects would be running and listening at all times, which is not the desired behavior.
  • An effect should dispatch a single action, use a reducer to modify state if multiple props of the state need to be modified
  • Prefer the use of brackets and return statements in arrow function to increase debugability
// Prefer this
getAllBooks$ = createEffect(() => {
    return this.actions$.pipe(
        ofType(BooksPageActions.enter),
        mergeMap((action) => {
            return this.booksService
                .all()
                .pipe(
                    map((books: any) => BooksApiActions.booksLoaded({books}))
                )
        })
    );
})

// Instead of 
 getAllBooks$ = createEffect(() =>
    this.actions$.pipe(
       ofType(BooksPageActions.enter),
       mergeMap((action) =>
           this.booksService
               .all()
               .pipe(
                   map((books: any) => BooksApiActions.booksLoaded({books}))
               ))
    ))

What map operator should I use?

switchMap is not always the best solution for all the effects and here are other operators we can use.

  • mergeMap Subscribe immediately, never cancel or discard. It can have race conditions.

This can be used to Delete items, because it is probably safe to delete the items without caring about the deletion order

deleteBook$ = createEffect(() =>
        this.actions$.pipe(
            ofType(BooksPageActions.deleteBook),
            mergeMap(action =>
                this.booksService
                    .delete(action.bookId)
                    .pipe(
                        map(() => BooksApiActions.bookDeleted({bookId: action.bookId}))
                    )
            )
        )
    );
  • concatMap Subscribe after the last one finishes

This can be used for updating or creating items, because it matters in what order the item is updated or created.

createBook$ = createEffect(() =>
    this.actions$.pipe(
        ofType(BooksPageActions.createBook),
        concatMap(action =>
            this.booksService
                .create(action.book)
                .pipe(map(book => BooksApiActions.bookCreated({book})))
        )
    )
);
  • exhaustMap Discard until the last one finishes. Can have race conditions

This can be used for non-parameterized queries. It does only one request event if it gets called multiple times. Eg. getting all books.

getAllBooks$ = createEffect(() => {
    return this.actions$.pipe(
        ofType(BooksPageActions.enter),
        exhaustMap((action) => {
            return this.booksService
                .all()
                .pipe(
                    map((books: any) => BooksApiActions.booksLoaded({books}))
                )
        })
    )
})
  • switchMap Cancel the last one if it has not completed. Can have race conditions

This can be used for parameterized queries

Other effects examples

  • Effects does not have to start with an action

    @Effect() tick$ = interval(/* Every minute */ 60 * 1000).pipe(
    map(() => Clock.tickAction(new Date()))
    );
    
  • Effects can be used to elegantly connect to a WebSocket

    @Effect()
    ws$ = fromWebSocket("/ws").pipe(map(message => {
    switch (message.kind) {
      case “book_created”: {
        return WebSocketActions.bookCreated(message.book);
      }
      case “book_updated”: {
        return WebSocketActions.bookUpdated(message.book);
      }
      case “book_deleted”: {
        return WebSocketActions.bookDeleted(message.book);
       }
    }}))
    
  • You can use an effect to communicate to any API/Library that returns observables. The following example shows this by communicating with the snack bar notification API.
@Effect() promptToRetry$ = this.actions$.pipe(
 ofType(BooksApiActions.createFailure),
 mergeMap(action =>
    this.snackBar
        .open("Failed to save book.","Try Again", {duration: /* 12 seconds */ 12 * 1000 })
        .onAction()
        .pipe(
          map(() => BooksApiActions.retryCreate(action.book))
        )
   )
);
  • Effects can be used to retry API Calls
    @Effect()
    createBook$ = this.actions$.pipe(
    ofType(
      BooksPageActions.createBook,
      BooksApiActions.retryCreate,
    ),
    mergeMap(action =>
     this.booksService.create(action.book).pipe(
       map(book => BooksApiActions.bookCreated({ book })),
       catchError(error => of(BooksApiActions.createFailure({
         error,
         book: action.book,
       })))
    )));
    
  • It is OK to write effects that don't dispatch any action like the following example shows how it is used to open a modal
@Effect({ dispatch: false })
openUploadModal$ = this.actions$.pipe(
 ofType(BooksPageActions.openUploadModal),
 tap(() => {
    this.dialog.open(BooksCoverUploadModalComponent);
 })
);
  • An effect can be used to handle a cancelation like the following example that shows how an upload is cancelled
@Effect() uploadCover$ = this.actions$.pipe(
 ofType(BooksPageActions.uploadCover),
 concatMap(action =>
    this.booksService.uploadCover(action.cover).pipe(
      map(result => BooksApiActions.uploadComplete(result)),
      takeUntil(
        this.actions$.pipe(
          ofType(BooksPageActions.cancelUpload)
        )
))));