The Second Step into the World of RxJS: RxJS Operators — How to Learn and Why They Are Needed
Welcome to the second article on the topic of RxJS! If you've read the first part, chances are you've experimented with from(), interval() and got familiar with basic operations — filtering and transforming data. We will build upon this foundation to explore more sophisticated tools, so that RxJS can evolve from simply being "interesting experiments" to becoming a truly powerful tool for your projects. Note that this article, as well as the rest in this series, is aimed exclusively at beginners. No groundbreaking insights, revolutionary findings, or "brilliant" thoughts are expected to be found here for experienced users. The content is based on my understanding of how best to structure the learning process of the given topic. Why are operators so important? RxJS can be compared to a LEGO set: you receive a basic platform (Observable) and a variety of tools (operators) that allow you to create virtually any solution, whether it's asynchronous requests, event management, or user input processing. Today's question: there are so many operators! How do we master all of them without getting overwhelmed? Continuing the logic of the previous article, let's state the following: operators cover the widest range of tasks, but the key to success is starting with the most commonly used and easy-to-understand tools. This will help you quickly grasp their power and integrate them into real-world applications. How to choose the order of studying operators? In this article, we'll get acquainted with the basic core of operators that you should prioritize in your study plan (once you're familiar with operators like map and filter). I divide the learning process into three main stages: Basic operators for filtering and transforming data. (The easiest to start with: they work "out of the box" and are easy to apply to simple code.) Combination operators. (These allow you to merge data sources, select necessary streams, and switch between them.) Subscription management mechanisms. (Handling unsubscriptions, error processing, and stream completion management.) Today, we will continue progressing through the first stage by deepening our knowledge. In the previous article, you already learned about filter() and map(). Now, let’s introduce a new operator — take(). take() — Control the number of data items If your data stream starts becoming too long or you need to limit the number of data items processed, the take() operator comes in handy. For example, let’s say you only want to process the first three messages: import { interval } from 'rxjs'; import { take } from 'rxjs/operators'; interval(1000) .pipe(take(3)) // Specify that we only need the first 3 values .subscribe(value => console.log(value)); // Output: // 0 // 1 // 2 The take() operator tells the observable stream: “Thanks, but I only need a few of the initial items.” This is especially useful for infinite streams like interval. reduce and scan Now it's time to get acquainted with operators that allow you to work with the entire stream of data, rather than individual items. For example, when you need to transform a stream into a single final value or track the accumulation of data over time. Let’s dive deeper into the reduce and scan operators. Why are reduce and scan so important? Both operators are often compared to tools from regular arrays, such as Array.reduce(). However, instead of processing a fixed array, reduce and scan work with data streams that may come in gradually. It’s like someone delivering coins to you one by one, and you’re putting them in a piggy bank: reduce works like this: it collects the coins in a piggy bank and only tells you the total amount after the process is complete. scan works differently: it informs you of the intermediate totals every time you add a coin. Essentially, reduce is perfect for final calculations, while scan is great for working with real-time data. reduce: The Final Value Upon Completion Imagine you’re running a charity fundraiser. People send you donations one at a time, and your task is to calculate the total amount collected. Only when the fundraiser ends do you make the final announcement: import { from } from 'rxjs'; import { reduce } from 'rxjs/operators'; const donations = from([10, 20, 30]); // A stream of donations donations.pipe( reduce((acc, value) => acc + value, 0) // Summing up all the donations ).subscribe(total => console.log(`Total amount: ${total}`)); // Output: // Total amount: 60 How does it work? reduce() takes two arguments: Accumulator function (acc, value), which updates the accumulated value. Initial value of the accumulator (in this case, 0). In our example, we take each element from the stream (donation) and add it to the total sum acc. Note: You only get the result after the stream is fully completed. If the stream is infinite (e.g., interval), reduce

Welcome to the second article on the topic of RxJS! If you've read the first part, chances are you've experimented with from()
, interval()
and got familiar with basic operations — filtering and transforming data. We will build upon this foundation to explore more sophisticated tools, so that RxJS can evolve from simply being "interesting experiments" to becoming a truly powerful tool for your projects.
Note that this article, as well as the rest in this series, is aimed exclusively at beginners. No groundbreaking insights, revolutionary findings, or "brilliant" thoughts are expected to be found here for experienced users. The content is based on my understanding of how best to structure the learning process of the given topic.
Why are operators so important?
RxJS can be compared to a LEGO set: you receive a basic platform (Observable) and a variety of tools (operators) that allow you to create virtually any solution, whether it's asynchronous requests, event management, or user input processing.
Today's question: there are so many operators! How do we master all of them without getting overwhelmed?
Continuing the logic of the previous article, let's state the following: operators cover the widest range of tasks, but the key to success is starting with the most commonly used and easy-to-understand tools. This will help you quickly grasp their power and integrate them into real-world applications.
How to choose the order of studying operators?
In this article, we'll get acquainted with the basic core of operators that you should prioritize in your study plan (once you're familiar with operators like map
and filter
).
I divide the learning process into three main stages:
- Basic operators for filtering and transforming data. (The easiest to start with: they work "out of the box" and are easy to apply to simple code.)
- Combination operators. (These allow you to merge data sources, select necessary streams, and switch between them.)
- Subscription management mechanisms. (Handling unsubscriptions, error processing, and stream completion management.)
Today, we will continue progressing through the first stage by deepening our knowledge.
In the previous article, you already learned about filter()
and map()
. Now, let’s introduce a new operator — take()
.
take() — Control the number of data items
If your data stream starts becoming too long or you need to limit the number of data items processed, the take()
operator comes in handy. For example, let’s say you only want to process the first three messages:
import { interval } from 'rxjs';
import { take } from 'rxjs/operators';
interval(1000)
.pipe(take(3)) // Specify that we only need the first 3 values
.subscribe(value => console.log(value));
// Output:
// 0
// 1
// 2
The take() operator tells the observable stream: “Thanks, but I only need a few of the initial items.” This is especially useful for infinite streams like interval
.
reduce and scan
Now it's time to get acquainted with operators that allow you to work with the entire stream of data, rather than individual items. For example, when you need to transform a stream into a single final value or track the accumulation of data over time.
Let’s dive deeper into the reduce
and scan
operators.
Why are reduce and scan so important?
Both operators are often compared to tools from regular arrays, such as Array.reduce()
. However, instead of processing a fixed array, reduce
and scan
work with data streams that may come in gradually. It’s like someone delivering coins to you one by one, and you’re putting them in a piggy bank:
-
reduce
works like this: it collects the coins in a piggy bank and only tells you the total amount after the process is complete. -
scan
works differently: it informs you of the intermediate totals every time you add a coin.
Essentially, reduce
is perfect for final calculations, while scan
is great for working with real-time data.
reduce: The Final Value Upon Completion
Imagine you’re running a charity fundraiser. People send you donations one at a time, and your task is to calculate the total amount collected. Only when the fundraiser ends do you make the final announcement:
import { from } from 'rxjs';
import { reduce } from 'rxjs/operators';
const donations = from([10, 20, 30]); // A stream of donations
donations.pipe(
reduce((acc, value) => acc + value, 0) // Summing up all the donations
).subscribe(total => console.log(`Total amount: ${total}`));
// Output:
// Total amount: 60
How does it work?
-
reduce()
takes two arguments:-
Accumulator function
(acc, value)
, which updates the accumulated value. - Initial value of the accumulator (in this case, 0).
-
Accumulator function
- In our example, we take each element from the stream (donation) and add it to the total sum
acc
.
Note: You only get the result after the stream is fully completed. If the stream is infinite (e.g., interval
), reduce
simply won't work.
Example: Calculating Orders
Now, let's apply reduce
to a task where we have a list of orders, and we want to calculate the total revenue for a company:
import { from } from 'rxjs';
import { reduce } from 'rxjs/operators';
const orders = from([
{ id: 1, total: 100 },
{ id: 2, total: 250 },
{ id: 3, total: 50 }
]);
orders.pipe(
reduce((acc, order) => acc + order.total, 0) // Summing up revenues
).subscribe(totalRevenue => console.log(`Общий доход: $${totalRevenue}`));
// Output:
// Total amount: $400
With the same approach, you could count the number of elements, find the maximum/minimum, or concatenate text strings.
scan: Real-Time Tracking
Now imagine that you don’t want to wait for the final result and instead want to observe intermediate totals right away. For example, you start a timer during a run and want to see each new time update.
This is where scan()
comes to the rescue:
import { from } from 'rxjs';
import { scan } from 'rxjs/operators';
const donations = from([10, 20, 30]); // Stream of donations
donations.pipe(
scan((acc, value) => acc + value, 0) // Calculate the sum at each step
).subscribe(currentSum => console.log(`Current sum: ${currentSum}`));
// Output:
// Current sum: 10
// Current sum: 30
// Current sum: 60
The feature of scan
is that the result updates at every step, not just at the end.
Example with real-time data: Bank Account
Now imagine another example: you deposit and withdraw money from a bank account. The scan
operator is perfect for calculating the balance in real-time:
import { from } from 'rxjs';
import { scan } from 'rxjs/operators';
const transactions = from([100, -50, 200, -75]); // Deposits and withdrawals
transactions.pipe(
scan((balance, transaction) => balance + transaction, 0) // Calculating balance
).subscribe(currentBalance => console.log(`Current balance: $${currentBalance}`));
// Output:
// Current balance: $100
// Current balance: $50
// Current balance: $250
// Current balance: $175
Here, each new value updates the current account balance. You see the changes instantly, which is perfect for monitoring.
Comparison: Reduce vs Scan
To better understand, let's visually compare how reduce
and scan
work. For example, we process the same data stream [1, 2, 3]
:
-
reduce
: will return only the final result —6
. -
scan
: will first return1
, then3
, and finally6
.
Example code:
import { of } from 'rxjs';
import { reduce, scan } from 'rxjs/operators';
const numbers = of(1, 2, 3);
numbers.pipe(
reduce((acc, value) => acc + value, 0)
).subscribe(result => console.log(`reduce: ${result}`)); // reduce: 6
numbers.pipe(
scan((acc, value) => acc + value, 0)
).subscribe(result => console.log(`scan: ${result}`)); // scan: 1, 3, 6
When to use Reduce vs Scan?
Here are some tips for selecting the right operator:
Use reduce
when:
- You are working with a finite stream (e.g., a data array that ends).
- You are only interested in the final result.
- You need to calculate the total sum, product, or process all elements of the stream.
Use scan
when:
- You want to see data in real-time.
- The stream may be infinite.
- You need to track intermediate states: for example, balance calculation, accumulating sums, or monitoring updates.
What's Next?
After mastering these two tools, you can start combining them with other operators. For example, try adding filtering before reduce
or see how scan
behaves when combined with map
to transform intermediate states.
Further steps may include:
- Using
scan
for complex states, such as working with objects. - Exploring more advanced transformations with
reduce
to collect arrays or turn a data stream into more complex structures.
Experiment, challenge yourself with real-world tasks, and try to solve them in different ways — this is the best way to master RxJS.
Operators debounceTime and throttleTime
It's time to talk about event frequency management, where the main stars are debounceTime
and throttleTime
.
These operators are extremely useful when dealing with events generated at a high frequency (e.g., mouse clicks or text field inputs). They allow you to manage the number and frequency of these events to prevent resource overloading.
Why are debounceTime and throttleTime important?
Imagine the following scenario: a user is quickly typing into a search box, triggering a server request with each character entered. You don't want to send requests every millisecond, as this would overload the server. Instead, you want to optimize the process by handling only the latest input event after a short delay. This is where the debounceTime
operator comes in.
In another scenario, you might want to limit event handling frequency, allowing only one event to pass through every few seconds, such as when tracking mouse movement. For this, throttleTime
works perfectly.
Both operators act as filters for frequent events but handle them differently.
debounceTime: Wait for a pause between events
How does debounceTime work?
debounceTime
delays event processing until the stream has been silent for a specified amount of time. If a new event occurs within this time, the timer resets. As a result, debounceTime
always processes the last event in a series.
Example: Search with delay
At this point, we transition to examples that are more challenging to run in online tools such as PlayCode, mentioned in the first article. You may need to switch to local projects to execute the code.
Let's consider a classic task: a user enters a search query into a text field. To avoid creating a server request for every keystroke, we use debounceTime
:
import { fromEvent } from 'rxjs';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
const searchInput = document.getElementById('search');
// Create an event stream from the text input field
fromEvent(searchInput, 'input').pipe(
map((event: any) => event.target.value), // Extract text from the input field
debounceTime(300), // Wait 300ms after the last input
).subscribe(searchTerm => console.log(`Request: ${searchTerm}`));
// Output:
// The user quickly types "RxJ", then pauses on "RxJS".
// The console logs only: "Request: RxJS".
- How
debounceTime
works here:- If the user pauses for at least 300ms, the stream passes the last change.
- If the user continues typing, the timer resets.
- This approach is useful for optimizing input processing or other frequent events.
Example: Auto-save with delay
Now, another example: auto-saving a document. When a user makes changes to the text, saving does not happen instantly but after a pause to avoid too frequent requests:
import { fromEvent } from 'rxjs';
import { debounceTime, map } from 'rxjs/operators';
const textarea = document.getElementById('editor');
fromEvent(textarea, 'input').pipe(
map((event: any) => event.target.value), // Extract text from the editor
debounceTime(1000) // Wait 1 second after the last change
).subscribe(content => console.log(`Saving: ${content}`));
// The user writes the text: "Hello, RxJS".
// The save action is triggered only after a 1-second pause.
throttleTime: Limiting Event Frequency
How does throttleTime work?
throttleTime
allows passing events no more frequently than the specified time interval, ignoring all other events. If the stream generates multiple events during the interval, the operator will pass through only the first one.
Example: Tracking mouse position
Suppose you want to track mouse movements but do not want to overload the handler with a huge number of events. Instead, you want to receive the mouse coordinates once every 500ms:
import { fromEvent } from 'rxjs';
import { throttleTime, map } from 'rxjs/operators';
fromEvent(document, 'mousemove').pipe(
throttleTime(500), // Pass only one event every 500ms
map((event: MouseEvent) => ({ x: event.clientX, y: event.clientY }))
).subscribe(position => console.log(`Mouse coordinates: (${position.x}, ${position.y})`));
// Output:
// Move the mouse quickly across the screen. Coordinates are logged only once every 500ms.```
{% endraw %}
{% raw %}
How throttleTime
works:
- It's like starting a "timer" after the first event.
- All subsequent events are ignored until the specified interval of time has passed (in our case, 500ms).
Example: Preventing multiple button clicks
Let's consider a scenario: you have a button that sends a request to the server. To avoid sending multiple requests when the button is clicked repeatedly, we use throttleTime
. This ensures that only one click is processed every 2 seconds:
typescript
import { fromEvent } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
const button = document.getElementById('submit');
fromEvent(button, 'click').pipe(
throttleTime(2000) // Allow only one click every 2 seconds
).subscribe(() => console.log('Request sent!'));
// Output:
// The user quickly clicks the button 5 times in a row.
// Only one request is logged every 2 seconds.
Comparison: debounceTime vs throttleTime
Both operators allow you to control the frequency of events, but they do it differently. Here are their key differences:
Feature | debounceTime | throttleTime |
---|---|---|
How it works | Passes the event only after a pause. | Passes the first event in each interval. |
Best for | When you only care about the last event. | When you need to limit the frequency of events. |
Ignores events | All events until the pause ends. | All events during the interval. |
When to use debounceTime vs throttleTime?
Use debounceTime
if:
- You want to wait for the stream to go silent.
- You're working with searches, auto-saving, or other scenarios where you care about the last input.
- You want to reduce the number of requests.
Use throttleTime
if:
- You want to limit the event frequency.
- Events occur in real-time (e.g., mouse tracking, scrolling).
- You need to prevent multiple consecutive triggers of the same function within a short time.
The debounceTime
and throttleTime
operators help efficiently manage streams of frequent events, simplifying the handling of user interactions. With their help, you can avoid unnecessary computations, optimize server requests, and enhance the user experience.
As always, try using these operators in real-world tasks, experimenting with delay and interval parameters. Practice is the best way to master these useful tools!
mergeMap and switchMap
These operators are used to process data streams when each element of the original stream needs to be turned into a new stream. In other words, each element triggers an additional process, such as an asynchronous request or data transformation.
What are mergeMap and switchMap used for?
Imagine you have a data stream (e.g., button clicks, text input, or even a list of numbers), and each element triggers an additional action: sends a request, transforms data, or something else. The challenge is that such actions can spawn many parallel tasks, and managing them manually can be difficult.
Here’s what mergeMap
and switchMap
do:
-
mergeMap
starts all actions simultaneously (in parallel) and returns results as they are completed. -
switchMap
cancels the previous action if a new event occurs, returning results only for the latest.
mergeMap: Process all actions simultaneously
How does mergeMap work?
When using mergeMap
, every event in the stream triggers a new action, and all launched actions work together. It does not cancel old tasks but waits for all of them to complete.
Example: Multiple numbers — multiple text streams
Let’s look at an example. Pay close attention, as it’s a bit more complex.
We create a basic stream of numbers [1, 2]
.
For each element in this stream, we’ll start a new inner stream that returns a string in the format
. To keep the output manageable, we’ll limit the inner stream to two values.
typescript
import { from, interval } from 'rxjs';
import { mergeMap, take, map } from 'rxjs/operators';
// Stream of numbers
const numbers = from([1, 2]);
// Apply mergeMap
numbers.pipe(
mergeMap(num =>
interval(1000).pipe(
take(2),
map(i => `Number ${num}-${i}`) // Convert number to text
)
)
).subscribe(result => console.log(result));
// Output:
// Number 1-0
// Number 2-0
// Number 1-1
// Number 2-1
- Here, each element of the stream
[1, 2]
starts its own stream, and as tasks are completed, the composed strings are logged to the console. Notice the output: we don’t wait for one task to finish; both streams work simultaneously. - While we use a synthetic example here, the scenario is realistic. Just imagine that instead of numbers, we have user
IDs
, and for each ID, we want to make an asynchronous request to a remote server.
switchMap: Processing Only the Latest Action
How does switchMap work?
switchMap
works differently: it cancels all previous actions if a new event occurs. This is useful when you only need the latest action. For example, if a user is typing in a search bar, old requests become irrelevant when a new input is received.
Example: Multiple numbers, but only the latest matters
Let's take a stream of numbers and imagine that we want to update the output only for the latest number. If a new event occurs, the previous one is ignored:
typescript
import { from, interval } from 'rxjs';
import { switchMap, take, map } from 'rxjs/operators';
// Stream of numbers
const numbers = from([1, 2]);
// Apply switchMap
numbers.pipe(
switchMap(num =>
interval(1000).pipe(
take(2),
map(i => `Number ${num}-${i}`)
)
)
).subscribe(result => console.log(result));
// Output:
// Number 2-0
// Number 2-1
-
switchMap
cancels the processing of the first value when the second value arrives later.
Difference Between mergeMap and switchMap
While both operators map the source stream to new data streams, they are suited for different use cases:
Feature | mergeMap | switchMap |
---|---|---|
What it does | Executes all actions simultaneously. | Cancels previous actions, keeps only the latest one. |
When to use | If you're interested in the results of all tasks. | If you're interested only in the latest task. |
Example | Transforming all events (e.g., multiple clicks). | Text search, where only the most recent input matters. |
When to use mergeMap vs switchMap?
Use mergeMap
if:
- You want to process each event individually.
- You need to run multiple parallel asynchronous operations.
- All results are important, regardless of the order or completion time.
Example: Handling all mouse clicks or sending multiple requests to a server.
Use switchMap
if:
- You're only interested in the latest event.
- Results from previous events become irrelevant when a new one arrives.
- You're working with streams that update frequently — for example, a search field or a slider input.
It’s worth noting that understanding these two operators, in my opinion, is one of the most challenging aspects of RxJS. Simple examples may not fully explain the problems they solve. But as always, the key takeaway is to experiment. These are two essential operators, and while they may seem overly complex or rarely applicable at first, it’s enough to understand their general logic to recognize when they might be useful in real-world tasks.
Next Steps: Combining Knowledge
Now that you're able to work with the basic operators, it's time to create interesting combinations. Consider this task: every second, a number is generated, but you only want to see even numbers, and you also want to double them:
typescript
import { interval } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
interval(1000)
.pipe(
filter(value => value % 2 === 0), // Keep only even values
map(value => value * 2), // Double them
take(5) // Take only the first five values
)
.subscribe(value => console.log(value));
// Output:
// 0
// 4
// 8
// 12
// 16
Such combinations allow you to use several operators together to process a data stream. Your task here is to break the logic of the operators into steps to avoid confusion.
Conclusion
Getting into RxJS is a journey that requires time, experiments, and gradual learning. It's best to start small: try out basic operators like map
, filter
, and take
, understanding them through examples. This lays a solid foundation for working with more complex tools. Focus on the operators that helped solve specific problems or felt intuitive. Practice is the key to mastering any technology.
Don't feel pressured to learn all operators at once. RxJS is a powerful tool that reveals its potential as your experience grows. It’s enough to master a few key operators (as we have done in these articles) and then gradually expand your knowledge as the need arises. The official RxJS documentation is structured well for finding operators, making it the best resource for learning them once you’ve grasped the basics.
Remember: what matters most is not the number of operators you’ve learned but your comfort in using them and your understanding of data stream logic. Solve real-world problems, experiment with operators, combine them, and create your own small projects. This approach allows you to progress from simple to complex without feeling overwhelmed.
In the third article, we’ll discuss common mistakes in managing RxJS streams, which often complicate developers' lives. Stay productive — see you in the next step!