exhaustMap
exhaustMap()
is a higher-order mapping operator. When it receives a value from the source (outer) Observable:
- It checks if it's already executing an inner Observable from a previous source value.
- If it's NOT busy: It uses the new source value and your project function to create a new inner Observable, subscribes to it, and starts emitting its values.
- If it IS busy (meaning the inner Observable from a previous source value hasn't completed yet): It completely ignores the new value from the source Observable. It just drops it and does nothing further with it.
- It will only listen for and process a new source value once its current inner Observable completes.
Think of it like a busy worker who takes the first task assigned. While working on that task, they completely ignore anyone else trying to give them new tasks. Only when they finish the current task will they accept the next task that comes along. Any tasks attempted while they were busy are lost.
Key Characteristics#
- Higher-Order Mapping: Maps values from an outer Observable to inner Observables.
- Ignores While Busy: Discards incoming source values if an inner Observable is currently active.
- No Concurrency (Managed): Only ever handles one inner Observable at a time.
- No Cancellation: It doesn't cancel the active inner Observable; it lets it finish.
- Use Cases: Perfect for situations where you want to execute an action based on the first trigger in a potential burst of triggers, and then ignore all subsequent triggers until that action is fully complete. Common for preventing duplicate actions caused by rapid user input, like double-clicks.
Real-World Example Scenario#
It's Tuesday afternoon here in Bengaluru (just before 3 PM IST), and a classic scenario where exhaustMap
shines is preventing double form submissions.
Scenario: A user fills out a form in your Angular application and clicks the "Submit" button. This click should trigger an API call to save the data. However, users sometimes get impatient or accidentally double-click the button. If you used mergeMap
, you might send the same data twice concurrently. If you used concatMap
, the second click would be queued and executed after the first completes (still potentially undesirable). If you used switchMap
, the second click might cancel the first save attempt (definitely not what you want!).
You want the application to:
- Register the first click on "Submit".
- Start the API call (the inner Observable).
- Ignore any further clicks on the "Submit" button while that API call is in progress.
- Only after the first API call completes (successfully or with an error) should it listen for a new click again.
exhaustMap
handles this perfectly.
Code Snippet (Angular Component - Submit Button)#
import { Component, OnDestroy } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Subject, Subscription, Observable, of, timer } from "rxjs";
import { exhaustMap, catchError, tap, delay } from "rxjs/operators"; // Import exhaustMap
interface SubmitPayload {
formData: any;
timestamp: string;
}
interface SubmitResult {
success: boolean;
message: string;
payloadSent?: SubmitPayload;
}
@Component({
selector: "app-submit-once",
template: `
<h4>Submit Form Demo (Prevents Double Submit)</h4>
<p>
Rapidly click the "Submit" button. Only the first click while not busy
will trigger the action.
</p>
<button
(click)="onSubmitClick()"
class="btn btn-success"
[disabled]="isSubmitting"
>
<span
*ngIf="isSubmitting"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ isSubmitting ? " Submitting..." : "Submit Data" }}
</button>
<div class="mt-3">
<h5>Submission Log:</h5>
<ul class="list-group">
<li
*ngFor="let log of submissionLog"
class="list-group-item small"
[ngClass]="{
'list-group-item-info': log.includes('Ignoring'),
'list-group-item-warning': log.includes('Starting'),
'list-group-item-success': log.includes('Success'),
'list-group-item-danger': log.includes('Failed')
}"
>
{{ log }}
</li>
</ul>
</div>
`,
})
export class SubmitOnceComponent implements OnDestroy {
submissionLog: string[] = [];
isSubmitting = false;
// Use a Subject to stream click events
private submitSubject = new Subject<SubmitPayload>();
private submitSubscription: Subscription;
constructor(private http: HttpClient) {
this.submitSubscription = this.submitSubject
.pipe(
tap(() => {
// This tap happens *before* exhaustMap decides whether to proceed or ignore
// Useful for logging the intention, but not the actual start of the API call yet
console.log(
`[${new Date().toLocaleTimeString()}] Submit detected by Subject.`
);
// Note: We don't set isSubmitting = true here yet, only when exhaustMap *starts* the inner observable.
}),
// exhaustMap will ignore emissions from submitSubject if an inner observable is active
exhaustMap((payload: SubmitPayload) => {
// This inner function only runs if exhaustMap is NOT already busy.
this.isSubmitting = true; // Set loading state *now*
const startLog = `[${new Date().toLocaleTimeString()}] exhaustMap Starting API Call with payload from ${
payload.timestamp
}`;
console.log(startLog);
this.submissionLog.push(startLog);
// Simulate API call (replace with actual http.post)
// Add delay to simulate network time
const innerApiCall$ = of({
status: "Saved",
dataReceived: payload,
}).pipe(
delay(2500) // Simulate 2.5 second save operation
);
// const innerApiCall$ = this.http.post<any>('/api/formdata', payload)
return innerApiCall$.pipe(
map((response) => ({
success: true,
message: `Success: Submitted data from ${
payload.timestamp
}. Response: ${JSON.stringify(response)}`,
payloadSent: payload,
})),
catchError((error) => {
console.error("Submission Error:", error);
return of({
// Return failure result Observable
success: false,
message: `Failed: Submission from ${
payload.timestamp
}. Error: ${error.message || "Unknown error"}`,
payloadSent: payload,
});
}),
tap(() => {
this.isSubmitting = false; // Unset loading state when inner observable completes/errors
console.log(
`[${new Date().toLocaleTimeString()}] exhaustMap finished inner observable. Ready for next event.`
);
}) // Final tap ensures isSubmitting is reset
); // End of inner pipe
}) // End of exhaustMap
)
.subscribe({
next: (result: SubmitResult) => {
// Receives the result only when an API call initiated by exhaustMap completes
const logMsg = `[${new Date().toLocaleTimeString()}] ${
result.message
}`;
console.log(logMsg);
this.submissionLog.push(logMsg);
},
error: (err) => {
// Error in the main submitSubject stream (rare for Subject)
const logMsg = `[${new Date().toLocaleTimeString()}] Critical stream error: ${err}`;
console.error(logMsg);
this.submissionLog.push(logMsg);
this.isSubmitting = false;
},
});
// Monitor the subject separately to show when clicks are ignored
this.submitSubject.subscribe(() => {
if (this.isSubmitting) {
const ignoreLog = `[${new Date().toLocaleTimeString()}] Ignoring click because submission is already in progress.`;
console.warn(ignoreLog);
// Optionally push to a different log or provide brief UI feedback
this.submissionLog.push(ignoreLog);
}
});
}
onSubmitClick(): void {
// Create payload (e.g., from form values)
const payload: SubmitPayload = {
formData: { name: "Test User", value: Math.random() },
timestamp: new Date().toLocaleTimeString(),
};
// Push the payload onto the subject stream
// exhaustMap will decide whether to process it or ignore it
this.submitSubject.next(payload);
}
ngOnDestroy(): void {
if (this.submitSubscription) {
this.submitSubscription.unsubscribe();
console.log("Submit subscription stopped.");
}
this.submitSubject.complete();
}
}
Explanation:
Subject<SubmitPayload>
: A Subject (submitSubject
) streams the payloads whenever the "Submit Data" button is clicked viaonSubmitClick()
.exhaustMap((payload: SubmitPayload) => ...)
: This is the key operator.- When
submitSubject.next(payload)
is called,exhaustMap
receives thepayload
. - It checks if its previous inner Observable (
innerApiCall$
) is still running. - If NOT running: It executes the inner function, sets
isSubmitting = true
, logs the start, returnsinnerApiCall$
, and subscribes to it. - If IS running: It ignores the incoming
payload
. The inner function is not executed, no new API call is made, and theisSubmitting
flag remains true from the ongoing operation. The separate subscription logging demonstrates this ignoring action.
- When
innerApiCall$
: Represents the actual asynchronous work (simulatedhttp.post
with adelay
).- Inner
map
,catchError
,tap
: These handle the result of the API call and, importantly, the finaltap
setsisSubmitting = false
only when the inner operation completes or errors. This signals toexhaustMap
that it's no longer busy and can accept a new event fromsubmitSubject
. subscribe({...})
: Receives theSubmitResult
only for those clicks that were not ignored byexhaustMap
and whose corresponding API calls completed.
Summary#
use exhaustMap
when you want to ensure that an action triggered by an event stream only runs if it's not already running due to a previous trigger. It's the perfect tool for preventing duplicate submissions or actions caused by rapid, repeated events where only the first "available" trigger should be processed.