Decoding the Jargon: Declarative, Reactive, Data, and Action Streams
If you're newer to Angular, terms like "reactive" and "declarative" can feel like a mouthful. Data fetching makes sense, and most of us have used the HTTP Client, but what do these other concepts really mean? Let's strip this down to the fundamentals and build up from there. I'll take you through the starting points of a journey I'm still navigating myself. Let’s dive in.
The HTTP Client
Before we proceed, I'm going to assume you're comfortable with Angular basics like components and the HTTP Client. We won't do a deep dive into RxJS here—that would need a series of its own—but you should have a basic grasp of Observables, Subjects, and subscribing.
In most Angular apps, you'll eventually need to pull data from a server and present it to the user. Typically, we'll rely on methods like this:
This method sits inside a service and gets invoked from the component once it's initialized.
After that, we can use the user property in our class to build out the UI with structural directives:
getUsers() {
return this.http.get<Users[]>(`${this._rootUrl}/users`)
.pipe(catchError(err=> (
this.handleError(err)
))
}
This method is defined in a service and called from the component as it kicks off.
ngOnInit(): void {
//dont forget to unsubscribe!
this.subcription = this.userService.getUsers()
.subscribe(res => this.users = res)
}
With the users property in our class, we can leverage structural directives to render our view.
<div *ngFor="let user of users">{{user.name}}</div>
Is There a Smarter Way to Handle This?
That's the question I kept coming back to. There are certainly improvements we can make. One obvious one: using the Async Pipe to subscribe to the Observable, freeing us from having to manage subscriptions manually.
That alone is a big plus and a pattern many developers adopt. But I felt like it was missing something, especially when I hit a scenario at work where I had to merge data from two separate APIs. A procedural pattern felt like the wrong tool. Then I watched a talk by Deborah Kurata, and my curiosity spiked. That's when I started leaning toward the Declarative and Reactive approach.
Defining the Essentials
Let's set the stage by defining reactive and declarative. Frequently, we code in an imperative or procedural style, where you map out every individual step needed to reach a goal.
Think about navigating to a file buried deep in your file system. You don't remember the path, and you only have the terminal. You're typing ls and cd over and over until you locate it. That's procedural thinking—you describe each step of the journey.
So what's the declarative counterpart? Simply specifying whatever-the-file-is and letting the system figure out the rest. That works great if the machine knows how to resolve it, but often it doesn't, and we need to spell out the desired outcome along with the steps. Reactive programming is a bit trickier to explain, so I'll defer to this article: The introduction to Reactive Programming you've been missing.
Here’s the short version:
//C REACTS to changes in a or b
let a = 1;
let b = 2;
let c = a + b; //3
//at this point in time b = 3;
// C will now be 4. It recomputed its value based on changes to the things that make up its value.
Building Declarative Data Streams
We've looked at the old way. Now let's talk about where we're heading. Let's define a data stream:
allUsers$ = this.http.get<User[]>(`${this._baseUrl}/users`).pipe(
map(users =>
users.map(
user =>
({
...user,
username: `${user.username}@${user.website}`
} as User)
)
),
catchError(err => {
console.error(err);
return throwError(err);
}),
shareReplay(1)
);
Let's unpack that. In the service, we're declaring a class property named allUsers$. The $ suffix is a community convention indicating an Observable stream. With RxJS operators, you can transform the data however needed. Here, I'm using the map operator to take the User[] value, use Array.map() to reshape each User, and return a new object that includes a username property. We cast the result back to User to keep TypeScript's type inference on track.
Next up, error handling via catchError. In a production app, you'd likely use a Logger service to push logs to a server. For this example, we simply log to the console. That way, if something fails, we can pin down where in the service it happened. We return throwError so the error bubbles up to the subscriber (the component) for handling.
Finally, shareReplay(1). This operator caches the result and replays it for future subscribers. If two components subscribe at different times, the first one triggers the HTTP call. Because we're sharing and replaying, subsequent subscribers receive the original value without firing off another request.
Putting Data Streams to Work
Utilizing a data stream is refreshingly simple. Here's the recipe:
- Inject the service into your component:
private userService: UsersService - Store a reference to the data stream. Example:
this.users$ = this.userService.allUsers$.pipe(
//Our error thrown from the service bubbles to the component where we handle
//it. I'm just simply setting a property to true
//You have to return an Observable so I just return a empty observable that completes
catchError(err => {
this.error = true;
return EMPTY;
})
);
3. Subscribe with the Async pipe in your template!
<ng-container *ngIf="users$ | async as users">
Making Data Streams Reactive with Action Streams
Sometimes, the data in our apps is purely read-only. That simplifies things—we subscribe, display, and move on.
But other times, users need to interact with the data—editing, selecting, or acting on it. We refer to these interactions as Action Streams. These can be built with RxJS Subjects, letting you push values into them. By combining an action stream with your data stream, you can react to user actions and transform data using RxJS operators. Here's an example of an action stream emitting a selectedUser:
private selectedUserSubject = new BehaviorSubject<number>(null);
selectedUserAction$ = this.selectedUserSubject.asObservable();
onSelectedUser(id) {
this.selectedUserSubject.next(id);
}
Breaking that down: we have a BehaviorSubject that emits numbers and an Observable counterpart. A helper method, when triggered from the component, pushes the selected user's ID into that stream. Combined with the allUser$ data stream, we can create a new stream that emits the selected user and reacts to each action:
selectedUserData$: Observable<User> = combineLatest([
this.allUser$,
this.selectedUserAction$
]).pipe(
map(([allUsers, selectedUser]) => allUsers.find(u => u.id === selectedUser))
);
The selectedUserData$ property is set using the combineLatest operator. This grabs the most recent emission from both streams and returns them as an array. Using array destructuring within map, we locate the matching user in the allUsers array and return it. Every time a new ID enters the action stream, this pipe re-runs and emits an updated user.
Wrapping Up
Thanks for hanging in there! This approach brings a lot of flexibility and opens the door to reactive, push-based architectures. With these patterns, you'll notice your UI feels more responsive and fluid. Check out this StackBlitz to see it in action.
