NgRx Effects concurrency mapping for race condition prevention
0 reputation · 07 Sept 2024, 20:38 UTC
0 reputation · 07 Sept 2024, 20:38 UTC
NgRx Effects rely on RxJS flattening operators to manage the execution of asynchronous streams. When handling high-frequency actions that trigger API requests, the choice of operator determines whether previous requests are canceled, queued, or ignored.
While switchMap is commonly used to cancel pending requests and concatMap ensures sequential execution, there are scenarios where the desired behavior is to ignore new incoming actions until the current asynchronous operation completes to prevent duplicate submissions.
Which flattening operator is the most appropriate for ensuring a single active request while ignoring subsequent triggers? How does exhaustMap differ from switchMap in the context of NgRx Effect action streams?
31775 reputation · 08 Sept 2024, 00:36 UTC
exhaustMap is the appropriate flattening operator when you want to ignore new incoming actions while an asynchronous operation is already in progress. It prevents duplicate submissions without requiring manual loading-state tracking in the reducer.
| Operator | Behavior on New Action During Active Request | Typical Use Case |
|---|---|---|
exhaustMap |
Ignores the new action; current request continues uninterrupted | Form submissions, save buttons, navigation triggers where duplicates must be dropped |
switchMap |
Cancels the in-flight request and starts a new one | Search/typeahead, filter changes where only the latest request matters |
// Functional effect (NgRx 14+)
export const saveData$ = createEffect(() =>
actions$.pipe(
ofType(saveDataAction),
exhaustMap(({ payload }) =>
dataService.save(payload).pipe(
map(result => saveSuccess({ result })),
catchError(error => of(saveFailure({ error })))
)
)
)
);
The exhaustMap operator subscribes to the inner observable (the HTTP call) and ignores any further saveDataAction emissions until that inner observable completes or errors. This satisfies both constraints: unidirectional flow is preserved because the effect only dispatches new actions (saveSuccess or saveFailure), and no loading flag is needed in the reducer.
If the user must be notified that their action was ignored (e.g., toast message "Already saving"), exhaustMap alone cannot emit a notification for dropped actions. In that case, a custom operator or a concatMap with a guard in the service layer would be needed. Does your UI require feedback for ignored duplicate triggers?
Use comments to ask for clarification. Post a solution as an answer.
31,775 reputation · 08 Sept 2024, 00:35 UTC
While exhaustMap effectively prevents duplicate submissions, it is critical to ensure that the inner observable always completes, even during failures. If an API call fails and the error is not caught inside the exhaustMap, the entire Effect stream will terminate, and the application will stop responding to that action entirely.
To maintain the Effect's availability, the catchError operator must be placed on the inner observable rather than the outer pipe:
exhaustMap(({ payload }) =>
dataService.save(payload).pipe(
map(result => saveSuccess({ result })),
// Correct: Catch error here to keep the Effect alive
catchError(error => of(saveFailure({ error })))
)
)
If catchError were placed after exhaustMap, any single request failure would kill the stream, requiring a page refresh to restore functionality. This pattern ensures that once the error action is dispatched, the exhaustMap completes its cycle and becomes ready to accept new actions again.