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 (
next
notification). - React if something goes wrong (an
error
notification occurs). - Notice when the flow stops (
complete
notification).
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
tap
to 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 thesubscribe
block or using dedicated state management patterns. Thefinalize
operator 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 theloading
signal totrue
. - We call the (modified)
userDataService.getUser(id)
which returns an Observable. - We
pipe
this Observable through several operators:tap({...})
:- The
next
function insidetap
logs the receiveduserData
when (and if) thegetUser
Observable successfully emits data. It doesn't change theuserData
. - The
error
function logs the error if thegetUser
Observable fails. - The
complete
function 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 theerrorMessage
signal, and returnsEMPTY
so the stream terminates gracefully without crashing the application and allowsfinalize
to run.finalize(...)
: This is crucial for cleanup. It setsloading
back tofalse
regardless 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
next
handler insubscribe
is the primary place to update the component's main data state (theuser
signal). - The
error
andcomplete
handlers insubscribe
are less critical here becausecatchError
andfinalize
are 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.