One of the tenets of reactive/declarative code, at least in the context of Angular, is not to manually subscribe to observables.
Use the async
pipe, use toSignal
, hide the subscribe by using a library, do something else… just don’t manually subscribe.
But if we don’t want to be dogmatic about it, we need to understand more deeply why these manual subscribes are generally avoided, and why sometimes manual subscribes are necessary or even desirable.
This article is adapted from the following video:
Upside of Declarative
The idea for this video was provoked by one of my recent videos on a simple state management approach with signals and RxJS that involved using some manual subscriptions:
This came as a surprise to some people, as half of the videos on this channel are dedicated to reactive and declarative code and generally I advocate for avoiding manual subscribes.
But it’s kind of funny, because not only am I using manual subscribes here, I am doing it in a way that most blatantly violates the key reason for avoiding manual subscribes which is imperatively modifying state in the application. Yet that is exactly what I chose to do here.
So let’s first talk about why you don’t want to do that, and why I did that.
Downside of Subscribe
We’ve already covered the concepts and benefits declarative code many times over, so if you want an in depth explanation I’ll link to some videos here:
- My NEW default for state management in Angular
- What most devs don’t get about declarative code (I didn’t either)
But, the key idea is that with declarative code we can understand everything we need to know about a particular thing in our application by looking at its declaration:
articlesForPage$ = this.currentPage$.pipe(
startWith(1),
switchMap((page) =>
this.apiService.getArticlesByPage(page).pipe(
retry({
delay: () => this.retry$,
})
)
)
);
Take this articlesForPage
for example. I can see what this is and how it will change over time just by looking at the declaration of articlesForPage$
.
It’s value is derived from the currentPage
value - it will take that page value and use it to return a stream of articles from the getArticlesByPage
method. It will also initially start with a value of 1, and if the data we are trying to fetch fails to be retrieved, the fetch attempt will be retried every time this retry$
subject is triggered.
So, we know upfront how this thing behaves, and at any point we can also react to this value changing:
firstArticleOfPage$ = this.articlesForPage$.pipe(map((articles) => articles[0]));
We never need to update this derived value, it will just automatically update any time articlesForPage
changes.
When you can subscribe
But what happens when we manually subscribe? In short, it pulls our values out of this reactive and declarative paradigm with well contained definitions and automatic reactions, into the imperative paradigm which has spread out definitions and requires manual updates.
Let’s take a look at an alternative version of setting our articlesForPage
with a manual subscribe:
articlesForPage: Article[] = [];
init(){
this.goToPage(1);
}
retry(page: number){
this.goToPage(page);
}
goToPage(page: number){
this.apiService.getArticlesByPage(page).pipe(takeUntilDestroyed()).subscribe((articles) => {
this.articlesForPage = articles;
})
}
Now we can’t see the complete behavior of articlesForPage
and how it changes over time just by looking at its declaration. We need to look for anywhere it is referenced to see how it changes. This probably doesn’t seem like that big of a deal with a small snippet of code where everything is all together like this - but articlesForPage
could be changed anywhere, and as we add more code things start to get more tangled and we might end up missing some behaviour.
Not only that, but now we can no longer react to articlesForPage
changing.
firstArticleOfPage = this.articlesForPage[0];
This code will only run once for the initial value of articlesForPage
. If we want to make sure it updates properly, then we need to make sure to manually recalculate it every time articlesForPage
changes:
goToPage(page: number){
this.apiService.getArticlesByPage(page).pipe(takeUntilDestroyed()).subscribe((articles) => {
this.articlesForPage = articles;
this.firstArticleOfPage = articles[0];
})
}
And things like this are easily forgotten.
When to Avoid Subscribes
That is, in a nutshell, why we want to avoid subscribes when coding reactively and declaratively - subscribing makes values not reactive and not declarative.
But, that doesn’t mean we can NEVER subscribe in this paradigm. In fact, there has to be a subscription at some point to use the value. So when is it okay to subscribe?
Generally, you can subscribe manually if the data you are pulling out of the stream has finished its journey in the application.
It’s ok to subscribe to display data in the template - although this is typically handled by the async pipe or through a conversion to a signal, rather than a manual subscribe.
This is ok to do because the value isn’t being used to create some other state in the application and other parts of the application no longer need to react to the data, it’s just being displayed on the screen to the user now. You can kind of think of the template as the output of the application, and that is the end of the data’s journey.
It’s also ok to subscribe if data is leaving your application. For example you might want to subscribe to trigger a POST
request. This is also fine, as the data has reached the end of the journey in your application, it is leaving your application entirely and going somewhere else. There are ways to handle POST requests without manual subscribes, but this particular situation does not break the ideas of reactive and declarative code if you do manually subscribe assuming you aren’t then using the response from that POST request in your application.
Where you want to avoid subscribes is when the data is still travelling through your application, when it’s in the middle of its journey. In short, this means we don’t want to subscribe to an observable and then set some state in the application, like we did in our articlesForPage
example.
Breaking the Rules
This brings us back to the example that inspired this post, you might notice that I am very explicitly subscribing here in order to directly modify the state in the application - the very thing that we are supposed to avoid doing.
So, why is this ok? To be blunt… it’s not. I am just blatantly breaking the rules.
But I am breaking the rules with an understanding of why they are there in the first place, and what specifically I am trying to achieve by breaking them.
What I am doing here is creating one targeted area of imperative code, where we make the jump from one reactive paradigm with RxJS, to another reactive paradigm with signals. RxJS is great for managing events, signals are great for managing state, and this allows me to combine the two in a way that allows people to utilise the power of both, but it also doesn’t require newcomers to RxJS to understand the more intimidating aspects of RxJS.
Conclusion
In short, I am making a small sacrifice by introducing some imperative code that makes the approach as a whole much simpler to understand. And we don’t really lose much here - we do lose some declarativity but there is at least still only one place where the state is updated, and everything after this point of the state being set can be declarative. Since we are using signals we maintain the reactive aspect as we can easily react to values changing and derive new values.
It’s okay break the rules, as long as you first learn why the rules are there in the first place. It’s a good way to keep dogmatism out of your code, and to help develop your own ideas.