Skip to content

Commit 3a5e5d8

Browse files
feat(component-store): add tapResponse signature with observer object (#3829)
1 parent f6ce20f commit 3a5e5d8

File tree

3 files changed

+152
-35
lines changed

3 files changed

+152
-35
lines changed

modules/component-store/spec/tap-response.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,59 @@ describe('tapResponse', () => {
4343
expect(completeCallback).toHaveBeenCalledWith();
4444
});
4545

46+
it('should invoke finalize callback after next and complete', () => {
47+
const executionOrder: number[] = [];
48+
49+
of('ngrx')
50+
.pipe(
51+
tapResponse({
52+
next: () => executionOrder.push(1),
53+
error: () => executionOrder.push(-1),
54+
complete: () => executionOrder.push(2),
55+
finalize: () => executionOrder.push(3),
56+
})
57+
)
58+
.subscribe();
59+
60+
expect(executionOrder).toEqual([1, 2, 3]);
61+
});
62+
63+
it('should invoke finalize callback after error', () => {
64+
const executionOrder: number[] = [];
65+
66+
throwError(() => 'error!')
67+
.pipe(
68+
tapResponse({
69+
next: () => executionOrder.push(-1),
70+
error: () => executionOrder.push(1),
71+
complete: () => executionOrder.push(-1),
72+
finalize: () => executionOrder.push(2),
73+
})
74+
)
75+
.subscribe();
76+
77+
expect(executionOrder).toEqual([1, 2]);
78+
});
79+
80+
it('should invoke finalize callback after error when exception is thrown in next', () => {
81+
const executionOrder: number[] = [];
82+
83+
of('ngrx')
84+
.pipe(
85+
tapResponse({
86+
next: () => {
87+
throw new Error('error!');
88+
},
89+
error: () => executionOrder.push(1),
90+
complete: () => executionOrder.push(-1),
91+
finalize: () => executionOrder.push(2),
92+
})
93+
)
94+
.subscribe();
95+
96+
expect(executionOrder).toEqual([1, 2]);
97+
});
98+
4699
it('should not unsubscribe from outer observable on inner observable error', () => {
47100
const innerCompleteCallback = jest.fn<void, []>();
48101
const outerCompleteCallback = jest.fn<void, []>();
Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,82 @@
11
import { EMPTY, Observable } from 'rxjs';
2+
import { catchError, finalize, tap } from 'rxjs/operators';
23

3-
import { catchError, tap } from 'rxjs/operators';
4+
type TapResponseObserver<T, E> = {
5+
next: (value: T) => void;
6+
error: (error: E) => void;
7+
complete?: () => void;
8+
finalize?: () => void;
9+
};
410

11+
export function tapResponse<T, E = unknown>(
12+
observer: TapResponseObserver<T, E>
13+
): (source$: Observable<T>) => Observable<T>;
14+
export function tapResponse<T, E = unknown>(
15+
next: (value: T) => void,
16+
error: (error: E) => void,
17+
complete?: () => void
18+
): (source$: Observable<T>) => Observable<T>;
519
/**
620
* Handles the response in ComponentStore effects in a safe way, without
7-
* additional boilerplate.
8-
* It enforces that the error case is handled and that the effect would still be
9-
* running should an error occur.
21+
* additional boilerplate. It enforces that the error case is handled and
22+
* that the effect would still be running should an error occur.
23+
*
24+
* Takes optional callbacks for `complete` and `finalize`.
25+
*
26+
* @usageNotes
1027
*
11-
* Takes an optional third argument for a `complete` callback.
28+
* ```ts
29+
* readonly dismissAlert = this.effect<Alert>((alert$) => {
30+
* return alert$.pipe(
31+
* concatMap(
32+
* (alert) => this.alertsService.dismissAlert(alert).pipe(
33+
* tapResponse(
34+
* (dismissedAlert) => this.alertDismissed(dismissedAlert),
35+
* (error: { message: string }) => this.logError(error.message)
36+
* )
37+
* )
38+
* )
39+
* );
40+
* });
1241
*
13-
* ```typescript
14-
* readonly dismissedAlerts = this.effect<Alert>(alert$ => {
15-
* return alert$.pipe(
16-
* concatMap(
17-
* (alert) => this.alertsService.dismissAlert(alert).pipe(
18-
* tapResponse(
19-
* (dismissedAlert) => this.alertDismissed(dismissedAlert),
20-
* (error: { message: string }) => this.logError(error.message),
21-
* ))));
22-
* });
42+
* readonly loadUsers = this.effect<void>((trigger$) => {
43+
* return trigger$.pipe(
44+
* tap(() => this.patchState({ loading: true })),
45+
* exhaustMap(() =>
46+
* this.usersService.getAll().pipe(
47+
* tapResponse({
48+
* next: (users) => this.patchState({ users }),
49+
* error: (error: HttpErrorResponse) => this.logError(error.message),
50+
* finalize: () => this.patchState({ loading: false }),
51+
* })
52+
* )
53+
* )
54+
* );
55+
* });
2356
* ```
2457
*/
25-
export function tapResponse<T, E = unknown>(
26-
nextFn: (next: T) => void,
27-
errorFn: (error: E) => void,
28-
completeFn?: () => void
29-
): (source: Observable<T>) => Observable<T> {
58+
export function tapResponse<T, E>(
59+
observerOrNext: TapResponseObserver<T, E> | ((value: T) => void),
60+
error?: (error: E) => void,
61+
complete?: () => void
62+
): (source$: Observable<T>) => Observable<T> {
63+
const observer: TapResponseObserver<T, E> =
64+
typeof observerOrNext === 'function'
65+
? {
66+
next: observerOrNext,
67+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
68+
error: error!,
69+
complete,
70+
}
71+
: observerOrNext;
72+
3073
return (source) =>
3174
source.pipe(
32-
tap({
33-
next: nextFn,
34-
complete: completeFn,
35-
}),
36-
catchError((e) => {
37-
errorFn(e);
75+
tap({ next: observer.next, complete: observer.complete }),
76+
catchError((error) => {
77+
observer.error(error);
3878
return EMPTY;
39-
})
79+
}),
80+
observer.finalize ? finalize(observer.finalize) : (source$) => source$
4081
);
4182
}

projects/ngrx.io/content/guide/component-store/effect.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,11 @@ export class MovieComponent {
7373
</code-example>
7474

7575
## tapResponse
76+
7677
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:
77-
- `tap` that handles success and error
78-
- `catchError(() => EMPTY)` that ensures that the effect continues to run after the error.
78+
79+
- `tap` that handles success and error cases.
80+
- `catchError(() => EMPTY)` that ensures that the effect continues to run after the error.
7981

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

97+
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:
98+
99+
<code-example header="movies.store.ts">
100+
readonly getMoviesByQuery = this.effect&lt;string&gt;((query$) => {
101+
return query$.pipe(
102+
tap(() => this.patchState({ loading: true }),
103+
switchMap((query) =>
104+
this.moviesService.fetchMoviesByQuery(query).pipe(
105+
tapResponse({
106+
next: (movies) => this.patchState({ movies }),
107+
error: (error: HttpErrorResponse) => this.logError(error),
108+
finalize: () => this.patchState({ loading: false }),
109+
})
110+
)
111+
)
112+
);
113+
});
114+
</code-example>
115+
95116
## Calling an `effect` without parameters
96117

97118
A common use case is to call the `effect` method without any parameters.
@@ -101,13 +122,15 @@ To make this possible set the generic type of the `effect` method to `void`.
101122
readonly getAllMovies = this.effect&lt;void&gt;(
102123
// The name of the source stream doesn't matter: `trigger$`, `source$` or `$` are good
103124
// names. We encourage to choose one of these and use them consistently in your codebase.
104-
trigger$ => trigger$.pipe(
105-
exhaustMap(() => this.moviesService.fetchAllMovies().pipe(
106-
tapResponse(
107-
movies => this.addAllMovies(movies),
108-
(error) => this.logError(error),
125+
(trigger$) => trigger$.pipe(
126+
exhaustMap(() =>
127+
this.moviesService.fetchAllMovies().pipe(
128+
tapResponse({
129+
next: (movies) => this.addAllMovies(movies),
130+
error: (error: HttpErrorResponse) => this.logError(error),
131+
})
109132
)
110133
)
111134
)
112-
));
135+
);
113136
</code-example>

0 commit comments

Comments
 (0)