DEV Community

Cover image for RxJS in Practice: Reactive Programming, Operators, Patterns, and Real-World Examples
Abanoub Kerols
Abanoub Kerols

Posted on

RxJS in Practice: Reactive Programming, Operators, Patterns, and Real-World Examples

RxJS is not just a library of operators. It is a different way of thinking about asynchronous data.

If you work with Angular, Node.js, React, or any application that deals with asynchronous operations, you will eventually encounter problems involving:

  • HTTP requests
  • User input
  • WebSockets
  • Events
  • Timers
  • State changes
  • Multiple asynchronous operations
  • Cancellation
  • Race conditions
  • Retries
  • Error handling

RxJS provides a powerful model for solving these problems through Reactive Programming.

In this article, we will move from the theory to practical examples and finally build a realistic Angular-style application using RxJS.


1. What Is RxJS?

RxJS (Reactive Extensions for JavaScript) is a library for composing asynchronous and event-based programs using Observables.

Instead of thinking:

"I have a function that will eventually return a value."

You start thinking:

"I have a stream of values that may arrive over time."

For example:

const observable$ = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  subscriber.next(3);
});
Enter fullscreen mode Exit fullscreen mode

The observable represents a stream:

1 ───> 2 ───> 3 ───> complete
Enter fullscreen mode Exit fullscreen mode

But an Observable doesn't have to emit only three values.

It could represent:

User clicks
   ↓
click
   ↓
click
   ↓
click
   ↓
...
Enter fullscreen mode Exit fullscreen mode

Or:

HTTP Request
     ↓
Response
     ↓
Complete
Enter fullscreen mode Exit fullscreen mode

Or:

WebSocket
   ↓
Message
   ↓
Message
   ↓
Message
   ↓
Message
   ↓
...
Enter fullscreen mode Exit fullscreen mode

This is the fundamental idea behind RxJS.


2. What Is Reactive Programming?

Reactive programming is a programming paradigm based on:

Reacting to changes and events over time.

Traditional imperative programming often looks like:

const value = getValue();

console.log(value);
Enter fullscreen mode Exit fullscreen mode

You ask for a value and receive it.

Reactive programming looks more like:

value$.subscribe(value => {
  console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

You are saying:

"Whenever a value arrives, execute this logic."

This becomes extremely powerful when dealing with asynchronous systems.


3. The Core RxJS Concepts

RxJS revolves around several important concepts:

Observable
    ↓
Subscribe
    ↓
Receive values
    ↓
Operators
    ↓
Transform / Filter / Combine
    ↓
Observer
Enter fullscreen mode Exit fullscreen mode

The most important concepts are:

  1. Observable
  2. Observer
  3. Subscriber
  4. Subscription
  5. Operators
  6. Subject
  7. Schedulers

Let's understand them.


4. Observable

An Observable represents a stream of values over time.

Example:

import { Observable } from 'rxjs';

const observable$ = new Observable<number>(subscriber => {
  subscriber.next(10);
  subscriber.next(20);
  subscriber.next(30);

  subscriber.complete();
});
Enter fullscreen mode Exit fullscreen mode

Subscribe:

observable$.subscribe(value => {
  console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

Output:

10
20
30
Enter fullscreen mode Exit fullscreen mode

The $ suffix is a common naming convention:

users$
products$
orders$
searchResults$
Enter fullscreen mode Exit fullscreen mode

It usually means:

"This variable represents an Observable."

It is a convention, not a language requirement.


5. Observable Lifecycle

An Observable can emit three kinds of notifications:

next
error
complete
Enter fullscreen mode Exit fullscreen mode

Example:

const observable$ = new Observable<number>(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  subscriber.next(3);

  subscriber.complete();
});
Enter fullscreen mode Exit fullscreen mode

Conceptually:

next(1)
   ↓
next(2)
   ↓
next(3)
   ↓
complete()
Enter fullscreen mode Exit fullscreen mode

An Observable can also fail:

const observable$ = new Observable<number>(subscriber => {
  subscriber.next(1);

  subscriber.error(
    new Error('Something went wrong')
  );
});
Enter fullscreen mode Exit fullscreen mode

Then:

next(1)
   ↓
error
Enter fullscreen mode Exit fullscreen mode

Once an Observable sends either:

complete()
Enter fullscreen mode Exit fullscreen mode

or:

error(...)
Enter fullscreen mode Exit fullscreen mode

the stream terminates.


6. Observer

An Observer defines what should happen when the Observable emits values, errors, or completes.

observable$.subscribe({
  next: value => {
    console.log('Value:', value);
  },

  error: error => {
    console.error('Error:', error);
  },

  complete: () => {
    console.log('Completed');
  }
});
Enter fullscreen mode Exit fullscreen mode

This is more explicit than:

observable$.subscribe(value => {
  console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

For production code, the object form is often easier to maintain when error or completion handling matters.


7. Subscription

When you subscribe:

const subscription = observable$.subscribe({
  next: value => console.log(value)
});
Enter fullscreen mode Exit fullscreen mode

you receive a Subscription.

You can unsubscribe:

subscription.unsubscribe();
Enter fullscreen mode Exit fullscreen mode

This matters for long-lived streams such as:

  • WebSockets
  • DOM events
  • intervals
  • Subjects
  • application-wide streams

Example:

const subscription = interval(1000).subscribe(value => {
  console.log(value);
});

setTimeout(() => {
  subscription.unsubscribe();
}, 5000);
Enter fullscreen mode Exit fullscreen mode

The interval stops after approximately five seconds.


8. Cold Observables

A cold Observable starts its producer separately for each subscriber.

Example:

const observable$ = new Observable<number>(subscriber => {
  console.log('Producer started');

  subscriber.next(Math.random());
});
Enter fullscreen mode Exit fullscreen mode

Subscribe twice:

observable$.subscribe(value => {
  console.log('Subscriber 1:', value);
});

observable$.subscribe(value => {
  console.log('Subscriber 2:', value);
});
Enter fullscreen mode Exit fullscreen mode

The producer runs twice.

Conceptually:

Subscriber 1
     ↓
Producer A

Subscriber 2
     ↓
Producer B
Enter fullscreen mode Exit fullscreen mode

Each subscriber gets its own execution.


9. Hot Observables

A hot Observable represents a shared source.

For example:

WebSocket
   ↓
   ├── Subscriber A
   ├── Subscriber B
   └── Subscriber C
Enter fullscreen mode Exit fullscreen mode

The source exists independently of individual subscribers.

This concept becomes especially important when using:

Subject
share()
shareReplay()
Enter fullscreen mode Exit fullscreen mode

10. Operators

Operators are one of the most important parts of RxJS.

They allow you to transform and control streams.

For example:

source$
  .pipe(
    map(value => value * 2),
    filter(value => value > 10)
  )
  .subscribe(value => {
    console.log(value);
  });
Enter fullscreen mode Exit fullscreen mode

Think of an RxJS pipeline as:

Source
  ↓
map
  ↓
filter
  ↓
subscribe
Enter fullscreen mode Exit fullscreen mode

11. map

map transforms every emitted value.

of(1, 2, 3)
  .pipe(
    map(value => value * 10)
  )
  .subscribe(console.log);
Enter fullscreen mode Exit fullscreen mode

Output:

10
20
30
Enter fullscreen mode Exit fullscreen mode

Conceptually:

1 → map → 10
2 → map → 20
3 → map → 30
Enter fullscreen mode Exit fullscreen mode

A very common example is extracting data from an HTTP response:

this.http.get<ApiResponse<User[]>>('/api/users')
  .pipe(
    map(response => response.data)
  );
Enter fullscreen mode Exit fullscreen mode

Now the consumer receives only:

User[]
Enter fullscreen mode Exit fullscreen mode

instead of the complete response object.


12. filter

filter allows only values matching a condition.

of(1, 2, 3, 4, 5)
  .pipe(
    filter(value => value % 2 === 0)
  )
  .subscribe(console.log);
Enter fullscreen mode Exit fullscreen mode

Output:

2
4
Enter fullscreen mode Exit fullscreen mode

Think:

1 ❌
2 ✅
3 ❌
4 ✅
5 ❌
Enter fullscreen mode Exit fullscreen mode

13. tap

tap allows you to perform side effects without changing the emitted value.

users$
  .pipe(
    tap(users => {
      console.log('Users:', users);
    })
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

tap is useful for:

  • Logging
  • Debugging
  • Analytics
  • Updating external state
  • Observing the pipeline

Avoid using tap to secretly transform data.

Bad:

tap(user => {
  user.name = 'Changed';
})
Enter fullscreen mode Exit fullscreen mode

Prefer transformations through operators such as:

map(user => ({
  ...user,
  name: 'Changed'
}))
Enter fullscreen mode Exit fullscreen mode

14. debounceTime

One of the most practical RxJS operators.

Imagine a search box.

Without debouncing:

c
ca
cat
cats
Enter fullscreen mode Exit fullscreen mode

You might send four HTTP requests.

With:

debounceTime(300)
Enter fullscreen mode Exit fullscreen mode

the application waits until the user stops typing for 300ms.

searchTerm$
  .pipe(
    debounceTime(300)
  )
Enter fullscreen mode Exit fullscreen mode

Conceptually:

c
ca
cat
cats
         ↓ 300ms
       "cats"
Enter fullscreen mode Exit fullscreen mode

Only "cats" continues through the pipeline.


15. distinctUntilChanged

This operator prevents consecutive duplicate values.

of(
  'Angular',
  'Angular',
  'React',
  'React',
  'Vue'
)
.pipe(
  distinctUntilChanged()
)
.subscribe(console.log);
Enter fullscreen mode Exit fullscreen mode

Output:

Angular
React
Vue
Enter fullscreen mode Exit fullscreen mode

Very useful for:

Search inputs
Filters
Route parameters
State changes
Enter fullscreen mode Exit fullscreen mode

16. switchMap

switchMap is one of the most important RxJS operators.

Imagine:

User searches:
Angular
Angular RxJS
Angular RxJS operators
Enter fullscreen mode Exit fullscreen mode

Each search creates an HTTP request.

You usually don't want an old request to overwrite the latest result.

switchMap solves this by switching to the newest inner Observable and unsubscribing from the previous one.

searchTerm$
  .pipe(
    switchMap(term => this.searchApi(term))
  )
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Search A
   ↓
Request A

Search B
   ↓
Cancel/Unsubscribe A
   ↓
Request B

Search C
   ↓
Cancel/Unsubscribe B
   ↓
Request C
Enter fullscreen mode Exit fullscreen mode

This makes switchMap ideal for:

  • Search
  • Autocomplete
  • Route parameter changes
  • Refreshing data
  • User-driven queries

17. mergeMap

mergeMap subscribes to inner Observables concurrently.

ids$
  .pipe(
    mergeMap(id => this.getUser(id))
  );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

ID 1 → Request 1
ID 2 → Request 2
ID 3 → Request 3

All can run concurrently.
Enter fullscreen mode Exit fullscreen mode

Use it when previous operations should not be cancelled.

Typical use cases:

  • Independent HTTP requests
  • Processing multiple items
  • Concurrent operations

18. concatMap

concatMap queues inner Observables and runs them sequentially.

actions$
  .pipe(
    concatMap(action => this.save(action))
  );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Action A
   ↓
Request A
   ↓
Complete

Action B
   ↓
Request B
   ↓
Complete

Action C
   ↓
Request C
Enter fullscreen mode Exit fullscreen mode

Use it when order matters.

For example:

Create record A
      ↓
Update A
      ↓
Delete A
Enter fullscreen mode Exit fullscreen mode

You may not want these operations running concurrently.


19. exhaustMap

exhaustMap ignores new emissions while the current inner Observable is running.

Example:

submitClick$
  .pipe(
    exhaustMap(() => this.submitForm())
  );
Enter fullscreen mode Exit fullscreen mode

Imagine the user double-clicks:

Click
 ↓
Request starts

Click
 ↓
IGNORED

Click
 ↓
IGNORED

Request completes
Enter fullscreen mode Exit fullscreen mode

This is extremely useful for preventing duplicate submissions.


20. The Four Important Mapping Operators

A useful mental model:

Operator Behavior
switchMap Cancel previous
mergeMap Run concurrently
concatMap Queue sequentially
exhaustMap Ignore while busy

Think about them like this:

switchMap
Latest wins

mergeMap
Everything runs

concatMap
One by one

exhaustMap
First wins while busy
Enter fullscreen mode Exit fullscreen mode

Choosing the correct flattening operator is one of the most important RxJS skills.


21. catchError

Errors are inevitable.

RxJS provides:

catchError()
Enter fullscreen mode Exit fullscreen mode

Example:

this.http.get<User[]>('/api/users')
  .pipe(
    catchError(error => {
      console.error(error);

      return of([]);
    })
  );
Enter fullscreen mode Exit fullscreen mode

Instead of terminating the application flow unexpectedly, the stream returns an empty array.

However, error handling should reflect the application's requirements.

Sometimes you should recover:

catchError(() => of([]))
Enter fullscreen mode Exit fullscreen mode

Sometimes you should rethrow:

catchError(error => {
  return throwError(() => error);
})
Enter fullscreen mode Exit fullscreen mode

22. retry

Transient failures can sometimes be retried.

this.http.get('/api/data')
  .pipe(
    retry(3)
  );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Request
 ↓
Fail
 ↓
Retry
 ↓
Fail
 ↓
Retry
 ↓
Fail
 ↓
Retry
Enter fullscreen mode Exit fullscreen mode

But blindly retrying everything is a bad idea.

For example, retrying a validation error won't usually solve anything.

Retries are more appropriate for transient failures.


23. finalize

finalize runs when the Observable terminates because of completion, error, or unsubscription.

Example:

this.loading = true;

this.http.get('/api/users')
  .pipe(
    finalize(() => {
      this.loading = false;
    })
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

This is useful for cleanup:

Start request
   ↓
loading = true
   ↓
HTTP request
   ↓
complete/error/unsubscribe
   ↓
loading = false
Enter fullscreen mode Exit fullscreen mode

24. Creating Observables

RxJS provides many creation functions.

of

of(1, 2, 3)
Enter fullscreen mode Exit fullscreen mode

Emits:

1
2
3
Enter fullscreen mode Exit fullscreen mode

from

from([1, 2, 3])
Enter fullscreen mode Exit fullscreen mode

Also emits:

1
2
3
Enter fullscreen mode Exit fullscreen mode

But from can convert many iterable or Promise-like sources.

For example:

from(fetch('/api/users'))
Enter fullscreen mode Exit fullscreen mode

25. interval

Creates periodic emissions.

interval(1000)
  .subscribe(value => {
    console.log(value);
  });
Enter fullscreen mode Exit fullscreen mode

Output:

0
1
2
3
4
...
Enter fullscreen mode Exit fullscreen mode

This Observable does not naturally complete.

Therefore, long-lived subscriptions should be managed carefully.


26. timer

timer(2000)
Enter fullscreen mode Exit fullscreen mode

Emits after two seconds.

You can also create repeated emissions:

timer(0, 1000)
Enter fullscreen mode Exit fullscreen mode

Conceptually:

0
 ↓ 1 sec
1
 ↓ 1 sec
2
 ↓ 1 sec
3
...
Enter fullscreen mode Exit fullscreen mode

27. Subjects

A Subject is both:

  • an Observable
  • an Observer

Example:

const subject$ = new Subject<number>();

subject$.subscribe(value => {
  console.log('A:', value);
});

subject$.subscribe(value => {
  console.log('B:', value);
});

subject$.next(10);
subject$.next(20);
Enter fullscreen mode Exit fullscreen mode

Output:

A: 10
B: 10

A: 20
B: 20
Enter fullscreen mode Exit fullscreen mode

The Subject multicasts values to its subscribers.


28. BehaviorSubject

BehaviorSubject requires an initial value and stores the latest value.

const user$ = new BehaviorSubject<User | null>(null);
Enter fullscreen mode Exit fullscreen mode

When a new subscriber arrives, it immediately receives the current value.

Current value = User A

Subscriber joins
       ↓
Receives User A
Enter fullscreen mode Exit fullscreen mode

This makes BehaviorSubject useful for representing state.

Example:

private currentUserSubject =
  new BehaviorSubject<User | null>(null);

currentUser$ =
  this.currentUserSubject.asObservable();
Enter fullscreen mode Exit fullscreen mode

Then:

this.currentUserSubject.next(user);
Enter fullscreen mode Exit fullscreen mode

29. ReplaySubject

ReplaySubject can replay previous emissions.

const subject$ = new ReplaySubject<number>(2);

subject$.next(1);
subject$.next(2);
subject$.next(3);

subject$.subscribe(value => {
  console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

Output:

2
3
Enter fullscreen mode Exit fullscreen mode

because we configured it to replay the last two values.


30. combineLatest

Sometimes you need the latest value from multiple streams.

Example:

combineLatest([
  user$,
  settings$,
  permissions$
])
Enter fullscreen mode Exit fullscreen mode

Conceptually:

User ────────┐
             │
Settings ────┼──> combineLatest
             │
Permissions ─┘
Enter fullscreen mode Exit fullscreen mode

It emits when one source emits, after every source has emitted at least once.

Useful for:

Filters
UI state
User preferences
Multiple dependent inputs
Enter fullscreen mode Exit fullscreen mode

31. forkJoin

forkJoin waits for all supplied Observables to complete and then emits their final values.

Perfect for independent HTTP requests:

forkJoin({
  users: this.getUsers(),
  products: this.getProducts(),
  orders: this.getOrders()
})
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Users ────────┐
Products ─────┼──> forkJoin → result
Orders ───────┘
Enter fullscreen mode Exit fullscreen mode

Important distinction:

forkJoin is generally for "wait until all complete."

combineLatest is generally for "react whenever the latest values change."


32. withLatestFrom

Sometimes one Observable should trigger the operation while reading the latest value from another.

Example:

submit$
  .pipe(
    withLatestFrom(formValue$)
  )
Enter fullscreen mode Exit fullscreen mode

Think:

submit$ ────────────────┐
                        ↓
                    withLatestFrom
                        ↑
formValue$ ─────────────┘
Enter fullscreen mode Exit fullscreen mode

The submit event is the trigger.


33. RxJS and Angular

RxJS is deeply integrated into Angular.

Common examples include:

HttpClient
ActivatedRoute
Router events
Reactive Forms
Event streams
NgRx
WebSockets
Enter fullscreen mode Exit fullscreen mode

For example:

this.route.params
  .pipe(
    switchMap(params =>
      this.userService.getUser(params['id'])
    )
  );
Enter fullscreen mode Exit fullscreen mode

This is a classic Angular pattern.


34. Real-World Search Example

Let's build a search pipeline.

We have:

searchControl = new FormControl('');
Enter fullscreen mode Exit fullscreen mode

Then:

searchResults$ = this.searchControl.valueChanges.pipe(
  debounceTime(300),

  distinctUntilChanged(),

  filter(term => !!term && term.length >= 2),

  switchMap(term =>
    this.searchService.search(term)
  )
);
Enter fullscreen mode Exit fullscreen mode

Let's understand the pipeline:

User types
   ↓
valueChanges
   ↓
debounceTime
   ↓
distinctUntilChanged
   ↓
filter
   ↓
switchMap
   ↓
HTTP request
   ↓
results
Enter fullscreen mode Exit fullscreen mode

This is a real RxJS use case.


35. Why switchMap Matters Here

Imagine:

User types: angular

Request A starts
Enter fullscreen mode Exit fullscreen mode

Then:

User types: angular rxjs

Request B starts
Enter fullscreen mode Exit fullscreen mode

The old search is no longer relevant.

With:

switchMap()
Enter fullscreen mode Exit fullscreen mode

the pipeline switches to the newest request.

Without the correct concurrency strategy, an older response could potentially create stale UI state depending on how the requests are managed.


36. Preventing Duplicate Form Submissions

Consider:

submit$
  .pipe(
    exhaustMap(() => this.api.submitForm())
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

If the user clicks:

Submit
Submit
Submit
Enter fullscreen mode Exit fullscreen mode

while the first request is running:

Submit #1 → Request
Submit #2 → ignored
Submit #3 → ignored
Enter fullscreen mode Exit fullscreen mode

This is one of the clearest real-world uses for exhaustMap.


37. Sequential API Operations

Suppose we need:

Create Order
     ↓
Create Payment
     ↓
Send Confirmation
Enter fullscreen mode Exit fullscreen mode

You may need sequential execution.

For a stream of operations:

operations$
  .pipe(
    concatMap(operation =>
      this.execute(operation)
    )
  );
Enter fullscreen mode Exit fullscreen mode

concatMap preserves order by waiting for the previous inner Observable to complete.


38. Parallel Requests

If operations are independent:

ids$
  .pipe(
    mergeMap(id => this.loadUser(id))
  );
Enter fullscreen mode Exit fullscreen mode

Requests can execute concurrently.

If you need to limit concurrency, mergeMap also accepts a concurrency parameter:

mergeMap(
  id => this.loadUser(id),
  3
)
Enter fullscreen mode Exit fullscreen mode

Now at most three inner subscriptions run concurrently.


39. RxJS Pipeline Thinking

One of the biggest improvements in RxJS comes from learning to think in pipelines.

Instead of:

let result = input;

result = transform(result);

if (condition) {
  result = anotherTransform(result);
}

send(result);
Enter fullscreen mode Exit fullscreen mode

You can model the process as:

input$
  .pipe(
    transform(),
    filter(),
    anotherTransform(),
    tap(),
    ...
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

The stream becomes a data-processing pipeline.


40. Avoid Nested Subscriptions

A common beginner pattern is:

this.userService.getUser().subscribe(user => {

  this.orderService.getOrders(user.id).subscribe(orders => {

    console.log(orders);

  });

});
Enter fullscreen mode Exit fullscreen mode

This can become difficult to maintain.

Instead:

this.userService.getUser()
  .pipe(
    switchMap(user =>
      this.orderService.getOrders(user.id)
    )
  )
  .subscribe(orders => {
    console.log(orders);
  });
Enter fullscreen mode Exit fullscreen mode

This is often called:

Flattening Observables

and it is one of the main reasons higher-order mapping operators exist.


41. Higher-Order Observables

Suppose:

Observable<User>
Enter fullscreen mode Exit fullscreen mode

is normal.

But:

Observable<Observable<User>>
Enter fullscreen mode Exit fullscreen mode

is a higher-order Observable.

For example:

users$
  .pipe(
    map(user => this.getUserDetails(user.id))
  );
Enter fullscreen mode Exit fullscreen mode

Now the result becomes conceptually:

Observable
   ↓
Observable<User>
Enter fullscreen mode Exit fullscreen mode

This is where flattening operators become important:

switchMap
mergeMap
concatMap
exhaustMap
Enter fullscreen mode Exit fullscreen mode

They turn:

Observable<Observable<T>>
Enter fullscreen mode Exit fullscreen mode

into something like:

Observable<T>
Enter fullscreen mode Exit fullscreen mode

while applying different concurrency behavior.


42. shareReplay

Suppose multiple components need the same HTTP result.

Without sharing:

users$ = this.http.get<User[]>('/api/users');
Enter fullscreen mode Exit fullscreen mode

Depending on how it is consumed, multiple subscriptions can trigger multiple HTTP executions.

A common caching/sharing pattern is:

users$ = this.http.get<User[]>('/api/users')
  .pipe(
    shareReplay({ bufferSize: 1, refCount: true })
  );
Enter fullscreen mode Exit fullscreen mode

Conceptually:

             ┌── Component A
HTTP Request ─┼── Component B
             └── Component C
Enter fullscreen mode Exit fullscreen mode

The shared result can be replayed to later subscribers while the shared subscription is active.

Be deliberate with caching semantics, especially for data that can become stale.


43. Memory Leaks

Long-lived streams can cause memory leaks when subscriptions remain active unnecessarily.

Potential examples:

interval(...)
fromEvent(...)
WebSocket streams
Subjects
Enter fullscreen mode Exit fullscreen mode

Modern Angular provides tools such as:

takeUntilDestroyed()
Enter fullscreen mode Exit fullscreen mode

For example:

this.events$
  .pipe(
    takeUntilDestroyed(this.destroyRef)
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

This ties the subscription lifetime to the Angular destruction lifecycle.

The important principle is:

Subscription lifetime should match the lifetime of the work you actually need.


44. take

You can limit the number of emissions:

interval(1000)
  .pipe(
    take(3)
  )
  .subscribe(console.log);
Enter fullscreen mode Exit fullscreen mode

Output:

0
1
2
Enter fullscreen mode Exit fullscreen mode

Then the Observable completes.


45. takeUntil

Another common pattern:

source$
  .pipe(
    takeUntil(destroy$)
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

When:

destroy$.next();
Enter fullscreen mode Exit fullscreen mode

the subscription terminates.

In modern Angular applications, Angular's destruction utilities can often provide a cleaner alternative.


46. Error Handling Strategy

A production RxJS pipeline might look like:

this.api.getUsers()
  .pipe(
    retry(2),

    catchError(error => {
      this.logger.error(error);

      return of([]);
    }),

    finalize(() => {
      this.loading = false;
    })
  )
  .subscribe(users => {
    this.users = users;
  });
Enter fullscreen mode Exit fullscreen mode

Notice the responsibilities:

retry
 ↓
Transient failure handling

catchError
 ↓
Recovery

finalize
 ↓
Cleanup
Enter fullscreen mode Exit fullscreen mode

Each operator has a specific responsibility.


47. RxJS Architecture

A clean Angular application might have:

Component
    ↓
Facade / State Layer
    ↓
Service
    ↓
HttpClient
    ↓
Backend API
Enter fullscreen mode Exit fullscreen mode

RxJS can flow through all these layers.

For example:

Component
    
users$
    
UserService
    
HTTP Observable
    
Backend
Enter fullscreen mode Exit fullscreen mode

The component does not necessarily need to manually manage every asynchronous operation.


48. Async Pipe

Angular's async pipe can subscribe to an Observable in the template.

Example:

users$ = this.userService.getUsers();
Enter fullscreen mode Exit fullscreen mode

Template:

<ul>
  @for (user of users$ | async; track user.id) {
    <li>{{ user.name }}</li>
  }
</ul>
Enter fullscreen mode Exit fullscreen mode

The async pipe handles subscription and cleanup for the template binding.

This can reduce manual subscription management.


49. Observable vs Promise

Promises represent one eventual result:

Promise
   ↓
one result
Enter fullscreen mode Exit fullscreen mode

Observables can represent:

Observable
   ↓
0
1
2
3
...
Enter fullscreen mode Exit fullscreen mode

A Promise:

fetch('/api/users')
  .then(response => response.json());
Enter fullscreen mode Exit fullscreen mode

Observable:

this.http.get<User[]>('/api/users')
Enter fullscreen mode Exit fullscreen mode

Observables also provide rich operators for:

Transformation
Filtering
Cancellation
Combination
Concurrency
Retrying
Error handling
Composition
Enter fullscreen mode Exit fullscreen mode

50. Cancellation

One major difference is cancellation.

Promises do not natively provide the same compositional cancellation model as RxJS subscriptions.

With an Observable:

const subscription = source$.subscribe();

subscription.unsubscribe();
Enter fullscreen mode Exit fullscreen mode

This gives RxJS a powerful way to model work that should stop when it is no longer needed.

This becomes particularly useful with:

Search
Navigation
Live streams
Polling
User interactions
Enter fullscreen mode Exit fullscreen mode

51. RxJS Mental Model

A useful mental model is:

Observable
    ↓
Source of values

Operator
    ↓
Transforms or controls values

Subscription
    ↓
Starts/owns the execution

Observer
    ↓
Consumes values
Enter fullscreen mode Exit fullscreen mode

Think:

SOURCE
  ↓
  ↓
map
  ↓
filter
  ↓
switchMap
  ↓
catchError
  ↓
finalize
  ↓
SUBSCRIBER
Enter fullscreen mode Exit fullscreen mode

Once this mental model becomes natural, RxJS becomes much easier.


52. A Complete Real-World Example

Let's combine everything.

Imagine an Angular product search page.

Requirements:

  • User enters a search term.
  • Wait 300ms.
  • Ignore duplicate terms.
  • Ignore short terms.
  • Cancel previous searches.
  • Retry temporary failures.
  • Show loading state.
  • Return an empty result if the request fails.

Implementation:

searchResults$ = this.searchControl.valueChanges.pipe(

  debounceTime(300),

  distinctUntilChanged(),

  map(term => term?.trim() ?? ''),

  filter(term => term.length >= 2),

  tap(() => {
    this.loading = true;
  }),

  switchMap(term =>
    this.productService.search(term).pipe(

      retry(2),

      catchError(error => {
        console.error('Search failed:', error);

        return of([]);
      }),

      finalize(() => {
        this.loading = false;
      })
    )
  )
);
Enter fullscreen mode Exit fullscreen mode

This pipeline represents a complete reactive workflow:

User Input
    ↓
debounce
    ↓
remove duplicates
    ↓
normalize
    ↓
validate
    ↓
loading = true
    ↓
switchMap
    ↓
HTTP
    ↓
retry
    ↓
catchError
    ↓
finalize
    ↓
Results
Enter fullscreen mode Exit fullscreen mode

This is where RxJS becomes much more than just syntax.


53. How to Choose the Correct Operator

When you see an asynchronous problem, ask:

Do I need to transform values?

Use:

map
Enter fullscreen mode Exit fullscreen mode

Do I need to remove values?

Use:

filter
Enter fullscreen mode Exit fullscreen mode

Do I need to wait for user input to settle?

Use:

debounceTime
Enter fullscreen mode Exit fullscreen mode

Do I need to ignore consecutive duplicates?

Use:

distinctUntilChanged
Enter fullscreen mode Exit fullscreen mode

Do I need only the newest request?

Use:

switchMap
Enter fullscreen mode Exit fullscreen mode

Do I need concurrent operations?

Use:

mergeMap
Enter fullscreen mode Exit fullscreen mode

Do I need sequential operations?

Use:

concatMap
Enter fullscreen mode Exit fullscreen mode

Do I need to ignore new triggers while busy?

Use:

exhaustMap
Enter fullscreen mode Exit fullscreen mode

Do I need all requests to finish?

Use:

forkJoin
Enter fullscreen mode Exit fullscreen mode

Do I need the latest values from multiple streams?

Use:

combineLatest
Enter fullscreen mode Exit fullscreen mode

Do I need error recovery?

Use:

catchError
Enter fullscreen mode Exit fullscreen mode

Do I need retries?

Use:

retry
Enter fullscreen mode Exit fullscreen mode

Do I need cleanup?

Use:

finalize
Enter fullscreen mode Exit fullscreen mode

54. The Most Important RxJS Skill

The hardest part of RxJS is not memorizing operators.

It is understanding time and concurrency.

For example:

User Event A
      ↓
Request A

User Event B
      ↓
Request B
Enter fullscreen mode Exit fullscreen mode

What should happen to Request A?

Different answers produce different operators:

Cancel A        → switchMap

Keep A + B      → mergeMap

Wait A → B      → concatMap

Ignore B        → exhaustMap
Enter fullscreen mode Exit fullscreen mode

Once you understand this question, choosing the operator becomes much easier.


55. Common RxJS Mistakes

Mistake 1: Nested subscriptions

Avoid:

a$.subscribe(a => {
  b$.subscribe(b => {
    ...
  });
});
Enter fullscreen mode Exit fullscreen mode

Prefer composition:

a$
  .pipe(
    switchMap(a => b$)
  )
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Using subscribe everywhere

Don't subscribe just to pass data to another Observable.

Instead of:

this.userService.getUser().subscribe(user => {
  this.user$ = of(user);
});
Enter fullscreen mode Exit fullscreen mode

prefer:

this.user$ = this.userService.getUser();
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Choosing switchMap automatically

switchMap is not always the correct operator.

If every operation must complete:

concatMap
Enter fullscreen mode Exit fullscreen mode

may be better.

If operations are independent:

mergeMap
Enter fullscreen mode Exit fullscreen mode

may be better.

If duplicate clicks should be ignored:

exhaustMap
Enter fullscreen mode Exit fullscreen mode

may be better.


Mistake 4: Ignoring subscription lifetime

Long-running subscriptions should have intentional lifecycle management.


Mistake 5: Overusing Subjects

Subjects are useful, but not every Observable needs to become a Subject.

Prefer simple Observable composition when possible.


56. RxJS in One Diagram

The entire philosophy can be summarized as:

                    RxJS
                     │
                     ▼
                Observable
                     │
                     ▼
              Stream of values
                     │
          ┌──────────┴──────────┐
          ▼                     ▼
      Operators             Subscription
          │                     │
          ▼                     ▼
 Transform / Filter       Start / Cleanup
 Combine / Control             │
          │                     │
          └──────────┬──────────┘
                     ▼
                  Observer
                     │
                     ▼
              Application Logic
Enter fullscreen mode Exit fullscreen mode

57. Final Takeaways

RxJS becomes much easier when you stop thinking about it as:

"A huge library with hundreds of operators."

Instead, think of it as:

A way to model values and events over time.

The most important concepts to master are:

Observable
Observer
Subscription
Operators
Subjects
Higher-order Observables
Concurrency
Error handling
Cancellation
Enter fullscreen mode Exit fullscreen mode

And the operators you should know particularly well are:

map
filter
tap
debounceTime
distinctUntilChanged

switchMap
mergeMap
concatMap
exhaustMap

catchError
retry
finalize

combineLatest
forkJoin
withLatestFrom

take
takeUntil
share
shareReplay
Enter fullscreen mode Exit fullscreen mode

If you truly understand:

switchMap
mergeMap
concatMap
exhaustMap
Enter fullscreen mode Exit fullscreen mode

you already understand one of the most important parts of practical RxJS.

The goal is not to memorize every operator.

The goal is to look at a problem and ask:

"How should these values behave over time?"

Once you can answer that question, the RxJS operator usually becomes obvious.


Conclusion

RxJS is powerful because asynchronous behavior is rarely just:

request → response
Enter fullscreen mode Exit fullscreen mode

Real applications contain:

events
requests
cancellation
retries
multiple streams
user interactions
state changes
concurrency
errors
Enter fullscreen mode Exit fullscreen mode

RxJS gives us a unified model for composing all of them.

And that's why RxJS remains such an important skill for modern Angular and reactive JavaScript development.

Don't learn RxJS by memorizing operators. Learn it by understanding streams, time, cancellation, and concurrency.

Top comments (0)