RxJS

Thinking reactive with the SIP principle

A few months back we released RxJS best practices in Angular and a while before that Thinking reactively in Angular and RxJS. Both of these articles are focussing on “trying to make the mind switch towards reactive programming”.

Thinking reactive with the SIP principle — RxJS article by brechtbilliet on Angular In Depth
Thinking reactive with the SIP principle — RxJS article by brechtbilliet on Angular In Depth
On this page · 10 sections

Earlier this year we published two pieces on RxJS: one covering best practices in Angular, and another on adopting a reactive mindset. Both were aimed at helping developers shift their thinking toward streams.

Yet, when a problem grows in complexity, many of us prefer some structure — a defined path, a checklist, or a methodology that keeps us grounded. A repeatable approach helps navigate the trickier corners of reactive composition.

Writing pragmatic RxJS solutions for small cases is often trivial. The real challenge kicks in when multiple streams need to be combined, or when logic goes beyond the basics.

At StrongBrew, reactive programming is baked into our daily workflow. We rely on it heavily in Angular applications. This article introduces a principle we've developed for handling intricate RxJS scenarios in a consistent, opinionated way.

The examples here are specific to Angular, but the underlying concepts translate to any framework.

The scenario

We'll put together a small app that lets users search for starships using the swapi api. The requirements are:

  • Fetch initial data when the page loads
  • Allow text-based search for starships via an input field
  • Allow loading starships based on a pre-selected model
  • Allow loading a random model with a single action
  • Display a loading indicator during data retrieval
  • Cancel prior pending requests to prevent race conditions
  • Filter results by the number of passengers aboard — applied client-side

That's a fair amount of asynchronous behavior. Written imperatively, this kind of logic tends to sprawl. With RxJS, however, we can model the entire flow as a series of streams. Instead of tracking distinct actions, we treat everything as observable data.

The SIP principle

We at StrongBrew settled on a simple methodology we refer to as the SIP principle:

  • S: Source streams
  • I: Intermediate streams
  • P: Presentation streams

Source streams

Source streams capture all user-driven events. Typically, they correspond to outputs emitted from presentational components. Occasionally, a source stream might carry real-time data, though that's beyond the scope here.

Let's look at our app and identify these streams — the left side essentially lists all user interactions: Source streams

Stepping through SIP's first phase yields four source streams: searchTerm$, selectedModel$, randomModel$, and numberOfPassengers$.

Notice how each stream is named for the data it holds rather than the action it represents — there's no search$ here, but searchTerm$. That's a deliberate naming choice meant to reinforce a data-centric way of shaping flows.

Source streams are frequently subjects or framework-provided sources such as route parameters.

Here's our set of source streams for the app:

searchTerm$ = new ReplaySubject<string>(1);
selectedModel$ = new ReplaySubject<string>(1);
randomModel$ = new ReplaySubject<string>(1);
// needs an initial value
numberOfPassengers$ = new BehaviorSubject(1000000); 

The outputs from our components feed into these subjects, as shown:

<sidebar 
    (search)="searchTerm$.next($event)"
    (selectModel)="selectedModel$.next($event)"
    (randomModel)="randomModel$.next($event)"
    (changeNumberOfPassengers)="numberOfPassengers$.next($event)"
>
</sidebar>
...

Presentation streams

Once the source streams are laid out, the next step is to determine the presentation streams — the observables the template directly consumes to render its views.

To spot them, glance at the template and note which properties the child components expect. Below is the template with outputs stripped for clarity:

  <sidebar class="sidebar" 
    [models]="fixedModels" 
    [numberOfPassengers]=""
  >
  </sidebar>
  <div class="main">
    <starship-list 
        [starships]=""
        [loading]="">
    </starship-list>
  </div>

Three presentation streams stand out immediately: one for the passenger count, one for the list of starships, and one that signals loading state. Let’s fill those into the template:

  <sidebar class="sidebar" 
    [models]="fixedModels" 
    [numberOfPassengers]="numberOfPassengers$|async"
  >
  </sidebar>
  <div class="main">
    <starship-list 
        [starships]="filteredResults$|async"
        [loading]="loading$|async">
    </starship-list>
  </div>

So after this second step we’ve identified the following presentation streams: numberOfPassengers$, filteredResults$, and loading$.

Constructing the SIP diagram

With both sets of streams identified, we can draw out a SIP diagram to visualize how they connect.

Source streams, presentation streams

The task is to bridge from the source streams to the presentation streams. Two streams call for computation: filteredResults$ and loading$. The numberOfPassengers$ flows straight through as is, so no extra work is needed for it.

Starting with filteredResults$, its dependencies point to searchTerm$, selectedModel$, randomModel$, and numberOfPassengers$. To keep that wiring simple, intermediate streams can help.

Intermediate streams

Intermediate streams act as connectors between source and presentation layers. Let’s set up a query$ stream that gathers all search triggers, along with a results$ stream for the API responses.

SIP 1

We'll use bespoke marble diagrams to illustrate each segment of the SIP diagram throughout this walkthrough.

Constructing the query $

The initial intermediate stream is the query$, created by merging searchTerm$, selectedModel$, and randomModel$.

Query

These three streams combine into a single query$ output. There's no need to track whether a user typed, picked a model, or asked for a random one. Our attention narrows to that unified stream of query data.

Constructing the results $

Each new value arriving on query$ should trigger an API fetch. That’s a textbook case for the switchMap operator.

Results

Constructing the filteredResults $

With those pieces in place, completing the filteredResults$ stream is straightforward. Looking again at the SIP diagram, it’s formed by combining results$ with numberOfPassengers$.

Filtered results

The loading $

Our final presentation stream, loading$, also draws from the diagram. Its inputs are the query$ and results$ streams.

sip 2

Whenever query$ emits, the loading$ stream emits true. Conversely, each emission from results$ turns it to false. Mapping the former to true, the latter to false, and merging them yields our loading$ stream.

Here’s that logic captured in a last marble diagram: Loading

Deciding what to share

The SIP diagram is nearly full. However, a closer look reveals a subtle issue — the query$ node fans out twice, and results$ does as well. Each outgoing arrow corresponds to a downstream subscription feeding on that source. For cold observables, every subscription invokes the producer anew. While query$ is hot, results$ is not — it wraps an HTTP request that would fire once per subscription. With subscriptions from both loading$ and filteredResults$, that would trigger two separate HTTP calls. The SIP diagram makes these shared subscriptions explicit.

sip3

See it in action

Our SIP diagram is finalized, so we’re ready to write the implementation. Since this article focuses on the methodology itself, we won’t go line by line through the code. The full source is available in this StackBlitz example.

Wrapping up

Before you dive into a complex RxJS screen, sketch the flow on a whiteboard. The SIP principle works for our team, though it’s by no means the only route.

If you look through the StackBlitz code, you’ll likely note how concise it is. A handful of lines carry the core logic, and most edge cases are handled through careful reactive design.

Thanks to the async pipe, there’s no need for manual unsubscriptions — the framework sees to it automatically.

Acknowledgments

Special thanks to Jurgen van de Moere for helping us land on the right acronym.

And gratitude to our reviewers:

B
brechtbilliet

Writes about RxJS, Components, State. Active 2016–2022.

All 22 articles →