This piece covers the recommended approaches and common pitfalls for building reactive applications with RxJS within Angular. The guidance shared here stems from hands-on experience and should be viewed as one developer's perspective rather than a rigid standard.
The subjects we will go through in this article are:
- Learning how to think reactive
- Pipeable operators
- ASCII marble diagrams
- Using pure functions
- Avoiding memory leaks
- Avoiding nested subscribes
- Avoiding manual subscribes in Angular
- Don’t pass streams to components directly
- Don’t pass streams to services
- Sharing subscriptions
- When to use Subjects
- Clean-code practices
- Angular embraces RxJS
Note:
Throughout this article, observables are referred to as streams. Since streams here carry a $-suffix, a brief clarification is warranted. The naming convention sparks debate, but in the end it comes down to personal taste. I find it helpful because it makes distinguishing streams from plain objects straightforward. Still, this is a preference, not a recommended standard.
Learning how to think reactive
Reactive programming diverges sharply from imperative programming. It demands a significant mental shift. This change in mindset matters if we want to get the most out of RxJS. The goal is to move away from reasoning about discrete actions and embrace reasoning about streams. Some habits we have built over time need to be set aside, at least temporarily. This article offers practical examples and guidance for transitioning into reactive thinking within RxJS.
Pipeable operators
A key practice is to rely on pipeable operators. All operators shown in this article are of that form. Since version 5.5, RxJS has introduced these pipeable operators, which offer a cleaner import path compared to patch operators and bring treeshaking benefits. For a deeper dive, see this resource and this one.
The contrast between the older and newer approaches is shown here.
// BAD: This is the old way and should be avoided (patch operators)
// as we can see the operators (filter, map) are part of the
// Observable prototype
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
const new$ = Observable.interval$
.filter(v => v % 2 === 0)
.map(v => v * 2);
// GOOD: This is the new and improved way (lettable operators)
// we just use the pipe operator where we pass operators that
// we can import from 'rxjs/operators'
import {filter, map} from 'rxjs/operators';
const new$ = interval$
.pipe(
filter(v => v % 2 === 0),
map(v => v *2)
)
ASCII marble diagrams
Some developers claim: "If a code is truly good, it should speak for itself, and documentation is something we can skip." In various situations, I tend to agree. However, when dealing with intricate RxJS logic, a second thought is worth giving. Streams can quickly become tangled under these circumstances:
- When we consider the full lifecycle of streams (how long do they persist? when do they start? what ends them?)
- When we begin to merge and combine streams (each stream carries its own lifecycle, after all)
- When we subscribe several times, subscribe after a delay, or in some cases never subscribe at all
Marble diagrams provide a clear way to visualize streams, yet embedding them directly in code is not practical. An ASCII-based version of such diagrams exists that we can employ to document our more complex streams and illustrate how they relate to one another.
ASCII diagrams offer benefits that go beyond pure documentation:
- They encourage a visual way of reasoning
- Reviewing code becomes simpler, making it easier to verify that the implementation matches its intended behavior
- They work nicely on a whiteboard as a planning stage before any coding begins
- They can be typed straight into your IDE, effectively nudging your brain into a reactive mindset
- They are also useful for crafting unit tests: have a look at this excellent guide
The underlying principles of ASCII marble documentation are straightforward. Consider this basic illustration:
// ---a--b--c--d---e---...
// ---a--b--c--d---e|
// ---a--b--c--d---e#
// ---a--b-^-c--d---e
-(marks the passage of time)a-z(symbolize values pushed onto the stream)|(denotes the end of the stream, i.e., its completion)...(signals that the stream continues to exist)#(means an error was thrown)^(shows the moment of subscription (relevant only for hot streams))
Let us move to a concrete situation and look at how one might document it:
const interval$ = interval(1000) // 0--1--2--3--4--5--6...
const new$ = interval$
.pipe(
skip(1), // ---1--2--3--4--5--6...
take(5), // ---1--2--3--4--5|
filter(v => v % 2 === 0), // ------2-----4---|
map(v => v + 1) // ------3-----5---|
)
Give this a moment to settle in, as it may very well be THE KEY to making convoluted code sections understandable for anyone else. By glancing at this diagram, what happens becomes rather evident, along with how each operator influences the new$ stream illustrated above. When it comes to writing ASCII marble-diagrams, there is no single proper style. They can be positioned according to your taste. As with any documentation, a crucial rule remains: make sure it stays current!
Using pure functions
RxJS operates on functional reactive principles, which essentially translates to using pure functions when constructing our reactive pipelines. A function qualifies as pure when:
- It does not modify any state
- Given the same inputs, it consistently produces the same output
- It produces no side effects; external state stays untouched outside the function
At first, introducing side effects might seem like the practical path, but this usually suggests our reactive thinking is not complete. For this reason, minimizing side effects is highly recommended.
Avoiding memory leaks
To receive values from a stream, we must subscribe to it. This action results in the creation of a subscription. This subscription remains alive until the stream is completed or we call unsubscribe explicitly. Handling subscriptions with care is critical, and there are many cases where we need to clean up an active subscription ourselves to prevent memory leaks. Look at this demonstration:
class AppComponent implements OnInit {
ngOnInit() {
// The following stream will produce values every second
// 0--1--2--3--4--5--6--...
const interval$ = interval(1000);
// Even when this component gets destroyed,
// the stream will keep producing values...
// This means the console will keep on logging
// This is a classic example of a memory-leak
const subscription = interval$.subscribe(r => console.log(r));
}
}
To eliminate the memory-leak in that component, we can retain a reference to the subscription and clear it during Angular's ngOnDestroy() lifecycle hook:
class AppComponent implements OnInit, OnDestroy {
subscriptions = [];
ngOnInit() {
const interval$ = interval(1000);
const subscription = interval$.subscribe(r => console.log(r));
// manually keep track of the subscriptions in a subscription array
this.subscriptions.push(subscription);
}
ngOnDestroy() {
// when the component get's destroyed, unsubscribe all the subscriptions
this.subscriptions.forEach(sub => sub.unsubscribe());
}
}
But if we are juggling multiple subscriptions, this pattern gets messy quite fast. Earlier we noted that a subscription survives until we unsubscribe ourselves (as done in the code above, or until the stream completes on its own). A tidy way to address this is by employing a Subject that we push a value to in the ngOnDestroy() lifecycle hook:
class AppComponent implements OnInit, OnDestroy {
destroy$ = new Subject();
ngOnInit() {
// interval$: 0--1--2--3--4--5--6--...
// destroy$: -------------true|
// result: 0--1--2--3--4|
const interval$ = interval(1000);
interval$
// let the interval$ stream live
// until the destroy$ Subject gets a value
.pipe(takeUntil(this.destroy$))
.subscribe(r => console.log(r));
}
ngOnDestroy() {
// when the component get's destroyed, pass something to the
// destroy$ Subject
this.destroy$.next(true);
}
}
Avoiding nested subscribes
Having subscriptions inside other subscriptions is something to be avoided whenever possible. It leads to code that is difficult to read, overly intricate, and full of side effects. In essence, it forces you away from a reactive style of thinking. Here is such a case in Angular:
class AppComponent {
user: User;
constructor(
private route: ActivatedRoute,
private userService: UserService)
{
// when the params of the route changes,
// we want to fetch the user and set the user property
//
// VERY BAD: nesting subscribes is ugly and takes away
// the control over a stream
this.route.params
.pipe(map(v => v.id))
.subscribe(id =>
this.userService.fetchById(id)
.subscribe(user => this.user = user))
}
}
The above approach is viewed as poor practice. Favoring higher-order streams such as mergeMap or switchMap is the better route. Let us see that same pattern reworked:
class AppComponent {
user: User;
constructor(
private route: ActivatedRoute,
private userService: UserService)
{
// when the params of the route changes,
// we want to fetch the user and set the user property
//
// GOOD: we have created a single subscribe which makes
// the flow way easier and gives us the control we need
this.route.params
.pipe(
map(v => v.id),
switchMap(id => this.userService.fetchById(id))
)
.subscribe(user => this.user = user)
}
}
Avoiding manual subscribes in Angular
To consume a stream, a subscription is required; that is just how observables function. But when a component relies on values from five separate streams... would we really want to subscribe to each of them and manually assign every value to its own property just to get things working? That would be problematic, wouldn't it?
Angular offers a fantastic solution known as the async pipe. It enables direct consumption of streams right inside the template. This pipe handles three responsibilities for us:
- It subscribes to the stream and hands the resulting value to the component
- It automatically unsubscribes when the component is destroyed (cutting down on unsubscribe boilerplate)
- It handles change detection triggering automatically
The implication is that manual subscribes and unsubscribes become unnecessary. That simplifies the code considerably. Let’s check out the tidied version of the earlier example:
@Component({
...
template: `
<user-detail [user]="user$|async"></user-detail>
`
})
class AppComponent {
// expose a user$ stream that will be
// subscribed in the template with the async pipe
user$ = this.route.params.pipe(
map(v => v.id),
switchMap(id => this.userService.fetchById(id))
);
constructor(
private route: ActivatedRoute,
private userService: UserService) {
}
}
For those using React, I put together a library called react-rx-connect that tackles this issue. It links streams to component state and handles unsubscription automatically upon component destruction.
Keep streams out of component inputs
Software architecture often hinges on the idea of decoupling different pieces of code. Passing a stream directly into a child component can be considered a bad practice since it forges a strong bond between the parent and the child. When a child subscribes, it can inadvertently trigger side effects in the parent. Generally, we want to avoid having a dumb child component initiate data requests—that responsibility belongs to the smart component. Learn more about the distinction between smart and dumb components. A component should simply accept an object or a value, without needing to know if that data comes from a stream or from a static source.
// BAD
// app.component.ts
@Component({
selector: 'app',
template: `
<!--
BAD: The users$ steram is passed
to user-detail directly as a stream
-->
<user-detail [user$]="user$"></user-detail>
`
})
class AppComponent {
// this http call will get called when the
// user-detail component subscribes to users$
// We don't want that
users$ = this.http.get(...);
...
}
// user-detail.component.ts
@Component({
selector: 'user-detail',
template: `
`
})
class UserDetailComponent implements OnInit {
@Input() user$: Observable<User>;
user: User;
ngOnInit(){
// WHOOPS! This child component subscribes to the stream
// of the parent component which will do an automatic XHR call
// because Angular HTTP returns a cold stream
this.user$.subscribe(u => this.user = u);
}
}
A better approach is to manage the subscription within the parent component itself:
// GOOD
// app.component.ts
@Component({
selector: 'app',
template: `
<user-detail [user]="user$|async"></user-detail>
`
})
class AppComponent implements OnInit {
users$: Observable<User[]> = this.http.get(...);
user: User;
ngOnInit(){
// the app component (smart) subscribes to the user$ which will
// do an XHR call here
this.users$ = this.http.get(...);
}
...
}
// user-detail.component.ts
@Component({
selector: 'user-detail',
template: `
`
})
class UserDetailComponent {
// This component doesn't even know that we are using RxJS which
// results in better decoupling
@Input() user: User;
}
Now the child's role is clear and focused. user-detail is a pure, dumb component, fully decoupled from its parent.
If you do need to convert an input into a stream, there's a handy library for that: ngx-reactivetoolkit
Avoid giving streams to services
Passing streams to services might appear to be a pragmatic solution, but it can be seen as a bad practice when you consider decoupling again. When a service receives a stream, you lose control over its fate. The service might subscribe to it or combine it with another stream that has a longer lifespan, ultimately influencing the state of your application. Subscriptions created in a service can trigger unintended side effects. And ultimately, services should not be concerned with the fact that your components are using streams. Here's an example of what to avoid:
// BAD
// app.component.ts
class AppComponent {
users$ = this.http.get(...)
filteredusers$ = this.fooService
.filterUsers(this.users$); // Passing stream directly: BAD
...
}
// foo.service.ts
class FooService {
// return a stream based on a stream
// BAD! because we don't know what will happen here
filterUsers(users$: Observable<User[]>): Observable<User[]> {
return users$.pipe(
map(users => users.filter(user => user.age >= 18))
}
}
You can achieve better results with higher-order streams in these cases. Prefer switchMap over mergeMap, as it automatically unsubscribes from the previous inner stream. The example below is preferable because it centralizes all the RxJS logic in a single location where subscriptions are managed—the smart component.
// GOOD
// app.component.ts
class AppComponent {
users$ = this.http.get(...)
filteredusers$ = this.users$
.pipe(switchMap(users => this.fooService.filterUsers(users)));
...
}
// foo.service.ts
class FooService {
// this is way cleaner: this service doesn't even know
// about streams now
filterUsers(users: User[]): User[] {
return users.filter(user => user.age >= 18);
}
}
Sharing subscriptions
Most streams are cold by default, meaning each subscription invokes the producer logic. If you have multiple subscribers, you might not want to re-execute the producer every time. For instance, subscribing to Angular's http.get() multiple times triggers multiple xhr requests. In the following snippet, the xhr call fires twice because numberOfUsers$ is derived from users$, leading to two separate subscriptions.
@Component({
selector: 'app',
template: `
Number of users: {{numberOfUsers$|async}}
<users-grid [users]="users$|async"></users-grid>
`
})
// BAD
class AppComponent {
users$ = this.http.get(...)
// the subscription on this stream will execute the xhr call again
numberOfUsers$ = this.users$.pipe(map(v => v.length);
}
In such scenarios, it makes sense to share a single subscription. The next example uses the share() operator:
@Component({
selector: 'app',
template: `
Number of users: {{numberOfUsers$|async}}
<users-grid [users]="users$|async"></users-grid>
`
})
// GOOD
class AppComponent {
users$ = this.http.get(...).pipe(share());
// the subscription on this stream will execute the xhr call again
numberOfUsers$ = this.users$.pipe(map(v => v.length);
}
Sharing turns a stream hot. If you subscribe after the value has already been emitted, you will miss it. In those cases, shareReplay(1) might be a better fit than share(), as it retains the last emitted value for late subscribers.
A frequent mistake is overusing sharing. Hot streams are not always desirable, and there is a slight performance overhead. Additionally, lazy streams have their own benefits.
Angular provides a useful alternative that minimizes the need for shared streams: the async as else syntax. I would strongly consider this a best practice. The example below consolidates streams, cuts down on the number of subscriptions, and provides a straightforward mechanism for showing a loading indicator.
@Component({
selector: 'app',
template: `
<div *ngIf="users$|async as users; else loading">
Number of users:
<users-grid [users]="users"></users-grid>
</div>
<ng-template #loading>Loading...</ng-template>
`
})
class AppComponent {
// This stream will only subscribed to once
users$ = this.http.get(...);
}
Understanding when to use Subjects
A Subject acts as both a hot observable and an observer, allowing us to push values directly into the stream. Subjects are often overused by developers who haven't yet fully embraced reactive programming principles.
It's best to use them sparingly, like in the following situations:
For mocking streams in tests
const fetchAll$ = new Subject(); // use a Subject as a mock
usersServiceMock.fetchAll.mockReturnValue(fetchAll$);
fetchAll$.next(fakeUser);
For creating streams from Angular output events
@Component({
...
template: `
<some-component (search)="search$.next($event)"></some-component>
`
})
class AppComponent {
search$ = new Subject(); // ----t-----te-----ter----term...
}
For handling circular references
I won't go into full detail here, but Dominic Elm explains this concept very well in this in-depth article
For most other use cases, standard operators or Observable.create should be sufficient.
Note:
A BehaviorSubject is often used because of its getValue() method. However, relying on this is a bad practice. Reaching for a specific value usually indicates that you are not thinking reactively.
Code organization and readability
Consistent formatting can make complex streams easier to follow:
- Stack operators one below the other
foo$.pipe(
map(...)
filter(...)
tap(...)
)
- Break streams into separate, smaller ones when they get too long
- Move complex logic into private methods to keep the reactive flow readable
- Avoid extra parentheses for clarity, though this comes down to personal taste.
Leveraging RxJS with Angular
Angular is a framework that deeply integrates with RxJS, and we've seen just a few examples of how they work together. So it's wise to take advantage of the reactive features built into Angular.
ActivatedRouteprovides aparamsstream.- Both
HttpandHttpClientreturn streams. FormandFormControlexpose avalueChangesproperty that is a stream.- The async pipe is a powerful tool for working with streams directly in templates.
- Initializing streams inside
ngOnInit()makes mocking easier during tests.
Wrapping up
You made it to the end! That's a lot of ground covered. If you found this useful, consider looking into the "Advanced RxJS in Angular workshop" hosted by Strongbrew, where I and Kwinten Pisman explore how to apply advanced RxJS patterns in real-world Angular projects.

•