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

Apr 4, 2025 - 15:06
 0
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:

  1. 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.)
  2. Combination operators. (These allow you to merge data sources, select necessary streams, and switch between them.)
  3. 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:

  1. reduce works like this: it collects the coins in a piggy bank and only tells you the total amount after the process is complete.
  2. 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:
    1. Accumulator function (acc, value), which updates the accumulated value.
    2. 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 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 return 1, then 3, and finally 6.

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!