tap
The tap operator lets you perform side effects for notifications (next, error, complete) emitted by an Observable. A "side effect" is an action that doesn't directly modify the value passing through the stream itself.
Think of it like this: Data is flowing down a pipe (your Observable stream). tap allows you to attach a sensor to the side of the pipe. This sensor can:
- Look at the data flowing past (
nextnotification). - React if something goes wrong (an
errornotification occurs). - Notice when the flow stops (
completenotification).
Crucially, the sensor ( tap ) does not change the data flowing through the pipe. The same value that comes into tap goes out of tap to the next operator in the chain.
Why Use tap?#
Its primary purpose is performing actions that aren't part of the main data transformation logic:
- Logging: The most common use! Log values as they pass through a specific point in your stream to understand what's happening.
- Debugging: Temporarily insert
tap(console.log)to inspect values during development. - Updating External State (with caution): You could use
tapto update things outside the stream, like setting a loading flag or updating a Signal. However, be mindful – complex state logic is often better handled directly in thesubscribeblock or using dedicated state management patterns. Thefinalizeoperator is often preferred for cleanup actions like stopping loading indicators. - Triggering Other Actions: Maybe start a notification or trigger some non-critical background task based on an emission.
Real-World Example: Logging and Updating Loading State During Data Fetch#
Let's fetch some user data and use tap to log the progress and potentially update a loading state (though we'll use finalize for stopping the loading, as it's more robust).
Code Snippets#
1. Simple Data Service (user-data.service.ts)
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable, delay, of } from "rxjs"; // Import 'delay' and 'of' for simulation
export interface SimpleUser {
id: number;
name: string;
}
@Injectable({
providedIn: "root",
})
export class UserDataService {
private http = inject(HttpClient);
private apiUrl = "https://jsonplaceholder.typicode.com/users/"; // Fake API
getUser(id: number): Observable<SimpleUser> {
console.log(`UserDataService: Requesting user with ID: ${id}`);
// In a real app, use http.get:
// return this.http.get<SimpleUser>(`${this.apiUrl}${id}`);
// --- Simulation for predictable example ---
const fakeUser: SimpleUser = { id: id, name: `User ${id}` };
return of(fakeUser).pipe(delay(1500)); // Simulate network delay
// --- End Simulation ---
}
}
2. User Profile Component (user-profile.component.ts) - Uses tap
import {
Component,
inject,
signal,
ChangeDetectionStrategy,
OnInit,
DestroyRef,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { UserDataService, SimpleUser } from "./user-data.service"; // Adjust path
import { tap, catchError, finalize } from "rxjs/operators";
import { EMPTY, Observable } from "rxjs"; // Import EMPTY
@Component({
selector: "app-user-profile",
standalone: true,
imports: [CommonModule],
template: `
<h3>User Profile (Tap Example)</h3>
<button (click)="loadUser(1)" [disabled]="loading()">Load User 1</button>
<button (click)="loadUser(5)" [disabled]="loading()">Load User 5</button>
<button (click)="loadUser(999)" [disabled]="loading()">
Load User 999 (Will Error)
</button>
@if (loading()) {
<p>Loading user data...</p>
} @else if (errorMessage()) {
<p style="color: red;">Error: {{ errorMessage() }}</p>
} @else if (user()) {
<div>
<h4>{{ user()?.name }}</h4>
<p>ID: {{ user()?.id }}</p>
</div>
} @else {
<p>Click a button to load user data.</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserProfileComponent {
private userDataService = inject(UserDataService);
private destroyRef = inject(DestroyRef);
// --- State Signals ---
user = signal<SimpleUser | null>(null);
loading = signal<boolean>(false);
errorMessage = signal<string | null>(null);
loadUser(id: number): void {
this.loading.set(true);
this.errorMessage.set(null);
this.user.set(null);
console.log(`UserProfileComponent: Starting to load user ${id}`);
// --- Modify service call to handle potential error ---
let user$: Observable<SimpleUser>;
if (id === 999) {
// Simulate an error case
user$ = new Observable((observer) =>
observer.error(new Error(`User with ID ${id} not found`))
).pipe(delay(500)); // Simulate delay before error
} else {
user$ = this.userDataService.getUser(id);
}
// --- End modification ---
user$
.pipe(
// --- Using tap ---
tap({
// Side effect for NEXT notification (successful data emission)
next: (userData) => {
console.log(
"%c tap: Received user data in stream:",
"color: blue",
userData
);
// You could do other things here, like trigger analytics maybe.
// BUT: Notice we don't modify 'userData' here.
},
// Side effect for ERROR notification
error: (err) => {
console.error(
"%c tap: Encountered an error in stream:",
"color: red",
err.message
);
// We can log the error here, but handling (like setting UI state)
// is often better done in catchError or subscribe's error handler.
},
// Side effect for COMPLETE notification
// (Note: finalize is often more reliable for cleanup)
complete: () => {
console.log(
"%c tap: Stream completed (no more values expected).",
"color: green"
);
},
}),
// -----------------
// Handle errors properly. catchError stops the error from killing the stream
// and allows finalize to run.
catchError((err: Error) => {
this.errorMessage.set(err.message || "Failed to load user.");
// Return EMPTY or another observable to gracefully complete the stream
return EMPTY;
}),
// finalize runs when the observable completes OR errors (guaranteed cleanup)
finalize(() => {
this.loading.set(false);
console.log(
`UserProfileComponent: Finished loading attempt for user ${id}.`
);
}),
// Automatically unsubscribe when the component is destroyed
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (data) => {
// Update the main state in subscribe's next handler
this.user.set(data);
},
// Error handling primarily done in catchError now
error: (err) => {
/* Already caught and handled */
},
// Complete handler (optional, finalize often covers cleanup)
complete: () => {
console.log("UserProfileComponent: Subscribe detected completion.");
},
});
}
}
Explanation:
- When
loadUser()is called, we set theloadingsignal totrue. - We call the (modified)
userDataService.getUser(id)which returns an Observable. - We
pipethis Observable through several operators:tap({...}):- The
nextfunction insidetaplogs the receiveduserDatawhen (and if) thegetUserObservable successfully emits data. It doesn't change theuserData. - The
errorfunction logs the error if thegetUserObservable fails. - The
completefunction logs when the stream finishes normally. catchError(...): This properly handles potential errors. It catches the error from the stream (or fromtap's error handler if it threw one), sets theerrorMessagesignal, and returnsEMPTYso the stream terminates gracefully without crashing the application and allowsfinalizeto run.finalize(...): This is crucial for cleanup. It setsloadingback tofalseregardless of whether the stream completed successfully (next+complete) or errored out (error). This is generally safer than usingtap({ complete: ... })for UI state cleanup.takeUntilDestroyed(...): Standard practice for preventing memory leaks by unsubscribing when the component is destroyed.
- Finally,
.subscribe({...})is called to activate the entire chain.- The
nexthandler insubscribeis the primary place to update the component's main data state (theusersignal). - The
errorandcompletehandlers insubscribeare less critical here becausecatchErrorandfinalizeare handling those aspects for UI state updates.
- The
Run this code, click the buttons, and watch the console. You'll see the tap logs appearing before the final state updates in the subscribe or finalize blocks, demonstrating how tap lets you observe the stream's events without interfering with the main data flow or error handling logic.