Hello! Welcome to the second module of our course, Core Operators and Stream Transformation.
In the first module, we built a solid foundation by understanding what Observables are, how they differ from Promises, and how to create them from various sources. Now that we can produce streams of data, it's time to learn how to manipulate, inspect, and combine them. This module is where the real power of RxJS begins to shine.
Today's lesson focuses on a simple yet indispensable operator: tap. Our goal is to learn how to inspect stream emissions, errors, and completions for debugging, which is a crucial first step in working with any complex Observable chain. Think of tap as your diagnostic tool for looking inside the "pipes" of your streams without affecting what flows through them.
What are RxJS Operators?
Before we dive into tap, let's briefly clarify what operators are. In RxJS, operators are functions that take an Observable as input and return a new Observable. They are the primary way we work with asynchronous data, allowing us to build a processing pipeline for our streams.
To get a concise overview of this philosophy, please watch the first two minutes of the following video.
I only ever use *these* RxJS operators to code reactively
This video from Joshua Morony provides an excellent introduction to the role and philosophy of operators in RxJS.
Watch the section 'Introduction to RxJS Operators' from the beginning until 01:51. Focus on the core idea: operators allow us to define computations that apply to values as they pass through a stream, keeping the logic declarative.
The key takeaway is that we use operators to build a chain of operations—a pipe—that our data flows through, getting transformed, filtered, or inspected along the way.
Introducing tap: The Side-Effect Operator
The tap operator is your primary tool for performing side effects. A side effect is any operation that interacts with the world outside of the Observable's data flow, such as:
- Logging a value to the console.
- Updating
localStorage. - Triggering a navigation event.
- Dispatching an action to a state management library.
Crucially, tap does not modify the value in the stream. It simply "taps into" the stream to observe the value, performs its side effect, and then passes the original value along, completely unchanged.
The documentation on learnrxjs.io uses a great analogy.
The article 'tap / do' on learnrxjs.io provides a clear mental model for understanding this operator.
Read the section titled 'Why use tap?'. Pay attention to the 'surveillance camera' analogy and the distinction made between tap for side effects and map for transformation.
This distinction between observation (tap) and transformation (map) is fundamental. If you need to change the data, use map. If you just need to see the data or trigger an external action based on it, use tap.
tap in Action: Debugging and Side Effects
The most common use for tap is debugging. You can insert it anywhere in an operator chain to log the current value and see how it changes after each step.
Let's see some practical examples.
I only ever use *these* RxJS operators to code reactively
Joshua Morony's video demonstrates several real-world use cases for tap that are highly relevant to front-end development.
Watch the segment 'Tap Operator for Debugging and Side Effects' from 05:59 to 08:47. Notice how tap is used for simple console logging, triggering navigation in a route guard, and updating state—all without altering the stream itself.
As the video shows, tap is perfect for creating side effects in a predictable way. Placing side effects inside other operators like map is considered an anti-pattern because it makes the function "impure" and the code harder to reason about. The tap operator makes your intention explicit: "I am performing a side effect here."
Observing More Than Just Values
An Observable stream doesn't just emit next notifications (values). It can also send an error notification if something goes wrong, or a complete notification when it's finished. The tap operator can listen for all three.
To do this, you pass an "observer" object to tap with the corresponding callback functions: next, error, and complete.
import { of, throwError } from 'rxjs';
import { tap, map, catchError } from 'rxjs/operators';
const source$ = of(1, 2, 3, 4);
source$.pipe(
tap({
next: val => console.log(`[TAP] Emitted value: ${val}`),
error: err => console.error(`[TAP] Stream errored: ${err.message}`),
complete: () => console.log('[TAP] Stream completed.')
}),
map(val => {
if (val > 3) {
// This will be caught by the tap's error handler
throw new Error('Value is too high!');
}
return val * 10;
}),
catchError(err => {
// We'll cover catchError in a later lesson, but for now,
// know that it handles the error and prevents the app from crashing.
console.log(`[CATCH] Error handled. Returning a default value.`);
return of('Default');
})
).subscribe({
next: val => console.log(`[SUB] Received: ${val}`),
error: err => console.error(`[SUB] Subscriber error: ${err.message}`),
complete: () => console.log('[SUB] Subscriber complete.')
});
Expected Output:
[TAP] Emitted value: 1
[SUB] Received: 10
[TAP] Emitted value: 2
[SUB] Received: 20
[TAP] Emitted value: 3
[SUB] Received: 30
[TAP] Emitted value: 4
[TAP] Stream errored: Value is too high!
[CATCH] Error handled. Returning a default value.
[SUB] Received: Default
[SUB] Subscriber complete.
Notice how the tap operator's error callback fired right when the error occurred inside the map, allowing us to inspect it before it was handled by catchError. The complete callback for tap did not run because the original stream terminated with an error before it could complete naturally.
The official RxJS documentation provides a concise reference for this functionality.
The official RxJS documentation for tap details its signature and provides clear, minimal examples.
Skim the 'Description' and 'Examples' sections. Pay close attention to the function signature showing the optional next, error, and complete parameters. Also, note the warning about mutating objects, which we'll discuss next.
A Word of Caution: The Mutation Pitfall
The documentation mentions an important point: "Be careful! You can mutate objects as they pass through the tap operator's handlers."
While tap doesn't replace the emitted value, if that value is an object or an array (i.e., a reference type), the code inside tap can change its properties.
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
const source$ = of({ name: 'Alice', score: 90 });
source$.pipe(
tap(user => {
// ANTI-PATTERN: Mutating the object inside tap
console.log('Before mutation in tap:', JSON.stringify(user));
user.score = 100;
console.log('After mutation in tap:', JSON.stringify(user));
})
).subscribe(finalUser => {
// The subscriber receives the mutated object.
console.log('Subscriber received:', finalUser); // { name: 'Alice', score: 100 }
});
This is generally a bad practice because it creates an invisible, unexpected transformation. It violates the principle of tap being for observation. All transformations should be explicit and handled within a dedicated transformation operator like map. This keeps your data flow predictable and easier to debug.
Key Takeaways
tapis for side effects: Use it for logging, debugging, or interacting with external state without altering the stream.- It's transparent:
tappassesnext,error, andcompletenotifications through without changing them. - It observes all notifications: You can provide
next,error, andcompletecallbacks to inspect the full lifecycle of a stream. - It's your #1 debugging tool: When a stream behaves unexpectedly, inserting
tap(console.log)at various points in thepipeis the quickest way to diagnose the issue. - Avoid mutations: Do not modify objects or arrays within
tap. Perform all data transformations explicitly in operators likemap.
You now have a solid understanding of the first operator in our toolkit. Being able to safely inspect a stream is a skill you will use constantly as we build more complex reactive logic.
In our next lesson, we will move from observation to transformation. We'll explore two of the most common and powerful operators: map for transforming each value, and scan for accumulating state within a stream over time.
Can't find a good explanation? Sign up and we'll make it for you
Sign up