Skip to content

Commit

Permalink
feat(component-store): add tapResponse signature with observer object (
Browse files Browse the repository at this point in the history
  • Loading branch information
markostanimirovic authored Apr 21, 2023
1 parent f6ce20f commit 3a5e5d8
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 35 deletions.
53 changes: 53 additions & 0 deletions modules/component-store/spec/tap-response.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,59 @@ describe('tapResponse', () => {
expect(completeCallback).toHaveBeenCalledWith();
});

it('should invoke finalize callback after next and complete', () => {
const executionOrder: number[] = [];

of('ngrx')
.pipe(
tapResponse({
next: () => executionOrder.push(1),
error: () => executionOrder.push(-1),
complete: () => executionOrder.push(2),
finalize: () => executionOrder.push(3),
})
)
.subscribe();

expect(executionOrder).toEqual([1, 2, 3]);
});

it('should invoke finalize callback after error', () => {
const executionOrder: number[] = [];

throwError(() => 'error!')
.pipe(
tapResponse({
next: () => executionOrder.push(-1),
error: () => executionOrder.push(1),
complete: () => executionOrder.push(-1),
finalize: () => executionOrder.push(2),
})
)
.subscribe();

expect(executionOrder).toEqual([1, 2]);
});

it('should invoke finalize callback after error when exception is thrown in next', () => {
const executionOrder: number[] = [];

of('ngrx')
.pipe(
tapResponse({
next: () => {
throw new Error('error!');
},
error: () => executionOrder.push(1),
complete: () => executionOrder.push(-1),
finalize: () => executionOrder.push(2),
})
)
.subscribe();

expect(executionOrder).toEqual([1, 2]);
});

it('should not unsubscribe from outer observable on inner observable error', () => {
const innerCompleteCallback = jest.fn<void, []>();
const outerCompleteCallback = jest.fn<void, []>();
Expand Down
95 changes: 68 additions & 27 deletions modules/component-store/src/tap-response.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,82 @@
import { EMPTY, Observable } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';

import { catchError, tap } from 'rxjs/operators';
type TapResponseObserver<T, E> = {
next: (value: T) => void;
error: (error: E) => void;
complete?: () => void;
finalize?: () => void;
};

export function tapResponse<T, E = unknown>(
observer: TapResponseObserver<T, E>
): (source$: Observable<T>) => Observable<T>;
export function tapResponse<T, E = unknown>(
next: (value: T) => void,
error: (error: E) => void,
complete?: () => void
): (source$: Observable<T>) => Observable<T>;
/**
* Handles the response in ComponentStore effects in a safe way, without
* additional boilerplate.
* It enforces that the error case is handled and that the effect would still be
* running should an error occur.
* additional boilerplate. It enforces that the error case is handled and
* that the effect would still be running should an error occur.
*
* Takes optional callbacks for `complete` and `finalize`.
*
* @usageNotes
*
* Takes an optional third argument for a `complete` callback.
* ```ts
* readonly dismissAlert = this.effect<Alert>((alert$) => {
* return alert$.pipe(
* concatMap(
* (alert) => this.alertsService.dismissAlert(alert).pipe(
* tapResponse(
* (dismissedAlert) => this.alertDismissed(dismissedAlert),
* (error: { message: string }) => this.logError(error.message)
* )
* )
* )
* );
* });
*
* ```typescript
* readonly dismissedAlerts = this.effect<Alert>(alert$ => {
* return alert$.pipe(
* concatMap(
* (alert) => this.alertsService.dismissAlert(alert).pipe(
* tapResponse(
* (dismissedAlert) => this.alertDismissed(dismissedAlert),
* (error: { message: string }) => this.logError(error.message),
* ))));
* });
* readonly loadUsers = this.effect<void>((trigger$) => {
* return trigger$.pipe(
* tap(() => this.patchState({ loading: true })),
* exhaustMap(() =>
* this.usersService.getAll().pipe(
* tapResponse({
* next: (users) => this.patchState({ users }),
* error: (error: HttpErrorResponse) => this.logError(error.message),
* finalize: () => this.patchState({ loading: false }),
* })
* )
* )
* );
* });
* ```
*/
export function tapResponse<T, E = unknown>(
nextFn: (next: T) => void,
errorFn: (error: E) => void,
completeFn?: () => void
): (source: Observable<T>) => Observable<T> {
export function tapResponse<T, E>(
observerOrNext: TapResponseObserver<T, E> | ((value: T) => void),
error?: (error: E) => void,
complete?: () => void
): (source$: Observable<T>) => Observable<T> {
const observer: TapResponseObserver<T, E> =
typeof observerOrNext === 'function'
? {
next: observerOrNext,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
error: error!,
complete,
}
: observerOrNext;

return (source) =>
source.pipe(
tap({
next: nextFn,
complete: completeFn,
}),
catchError((e) => {
errorFn(e);
tap({ next: observer.next, complete: observer.complete }),
catchError((error) => {
observer.error(error);
return EMPTY;
})
}),
observer.finalize ? finalize(observer.finalize) : (source$) => source$
);
}
39 changes: 31 additions & 8 deletions projects/ngrx.io/content/guide/component-store/effect.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ export class MovieComponent {
</code-example>

## tapResponse

An easy way to handle the response in ComponentStore effects in a safe way, without additional boilerplate is to use the `tapResponse` operator. It enforces that the error case is handled and that the effect would still be running should an error occur. It is essentially a simple wrapper around two operators:
- `tap` that handles success and error
- `catchError(() => EMPTY)` that ensures that the effect continues to run after the error.

- `tap` that handles success and error cases.
- `catchError(() => EMPTY)` that ensures that the effect continues to run after the error.

<code-example header="movies.store.ts">
readonly getMovie = this.effect((movieId$: Observable&lt;string&gt;) => {
Expand All @@ -92,6 +94,25 @@ An easy way to handle the response in ComponentStore effects in a safe way, with
});
</code-example>

There is also another signature of the `tapResponse` operator that accepts the observer object as an input argument. In addition to the `next` and `error` callbacks, it provides the ability to pass `complete` and/or `finalize` callbacks:

<code-example header="movies.store.ts">
readonly getMoviesByQuery = this.effect&lt;string&gt;((query$) => {
return query$.pipe(
tap(() => this.patchState({ loading: true }),
switchMap((query) =>
this.moviesService.fetchMoviesByQuery(query).pipe(
tapResponse({
next: (movies) => this.patchState({ movies }),
error: (error: HttpErrorResponse) => this.logError(error),
finalize: () => this.patchState({ loading: false }),
})
)
)
);
});
</code-example>

## Calling an `effect` without parameters

A common use case is to call the `effect` method without any parameters.
Expand All @@ -101,13 +122,15 @@ To make this possible set the generic type of the `effect` method to `void`.
readonly getAllMovies = this.effect&lt;void&gt;(
// The name of the source stream doesn't matter: `trigger$`, `source$` or `$` are good
// names. We encourage to choose one of these and use them consistently in your codebase.
trigger$ => trigger$.pipe(
exhaustMap(() => this.moviesService.fetchAllMovies().pipe(
tapResponse(
movies => this.addAllMovies(movies),
(error) => this.logError(error),
(trigger$) => trigger$.pipe(
exhaustMap(() =>
this.moviesService.fetchAllMovies().pipe(
tapResponse({
next: (movies) => this.addAllMovies(movies),
error: (error: HttpErrorResponse) => this.logError(error),
})
)
)
)
));
);
</code-example>

0 comments on commit 3a5e5d8

Please sign in to comment.