In an imperative app, events trigger event handlers, which are containers of imperative code. This seems to be the most common approach in Angular apps, unfortunately. A typical app might have a function like this:
navigateBack() {
this.store.deleteMovies();
this.store.switchFlag(false);
this.router.navigate(['/']);
}
That event callback is controlling 3 pieces of state: store.movies
, store.flag
and the app's URL.
This isn't great separation of concerns, because logic that controls what store.flag
and store.movies
are is scattered across many callbacks such as navigateBack
, so to understand why anything is the way it is at a given time, you have to refer to many locations across the codebase. Basically, you need "Find All References", which can get really annoying.
So getting rid of event handlers/callbacks seems like a good potential place to start refactoring to more reactivity.
And what would the reactive version look like?
A simple rule for reactivity is this: Every user event in the template pushes the most minimal change to a single place in our TypeScript, then everything else reacts to that. Here's a diagram of that:
As you can see, the logic that controls each piece of state has been moved next to the state it controls. This makes it easier to avoid bugs, since we can easily refer to similar logic when writing new logic to control state.
Reactive Implementation
Now the question is how to implement this in code.
First, let's say we create a subject to represent the clicks on the back button:
backClick$ = new Subject<void>();
Now that we have an observable, what do we do with all these methods from the imperative click handler?
this.store.deleteMovies();
this.store.switchFlag(false);
this.router.navigate(['/']);
The particular app I'm working on is using NgRx/Component-Store, which, unfortunately, does not give us a way to update state reactively; there's nowhere to plug in our click source observable, so we have to call methods on a class.
We have 3 options that I know of: Create a custom wrapper around NgRx/Component-Store that lets us plug in observables; migrate to RxAngular/State; or migrate to StateAdapt, which isn't even on version 1.0 yet. I think the obvious choice is going to be StateAdapt, since I made it and I love it π (btw: 1.0 is coming within a month π€)
Edit: NgRx/Component-Store actually can take in observables to update state, but I like the override I created for select
because it passes subscriptions through to the update observables, just like StateAdapt.
Router State
But wait. What do we do about the router? There's no reactive alternative to this method:
this.router.navigate(['/']);
In my series on progressive reactivity in Angular, I wrote an article called Wrapping Imperative APIs in Angular. I complained about the Angular ecosystem lacking a lot of declarative APIs. For example, Angular is the only front-end framework out of the 9 most popular where every component library I've seen uses imperative APIs for opening and closing dialogs:
Framework | Library 1 | Library 2 | Library 3 |
---|---|---|---|
Vue | β Declarative | β Declarative | β Declarative |
React | β Declarative | β Declarative | β Declarative |
Svelte | β Declarative | β Declarative | β Declarative |
Preact | β Declarative | β Declarative | β Declarative |
Ember | β Declarative | β Declarative | β Declarative |
Lit | β Declarative | β Declarative | β Declarative |
SolidJS | β Declarative | β Declarative | --- |
Alpine | β Declarative | --- | --- |
Angular | β Imperative | β Imperative | β Imperative |
We shouldn't just accept this because this is how things have always been in Angular. Instead, we should create wrapper components that can be used declaratively in templates and abstract away the imperative this.dialog.open()
method calls.
But what about router state?
I'm not aware of any front-end framework that has a way to have the URL react to application state. That doesn't itself mean it's a terrible idea, but maybe it is.
I've thought quite a bit about this and can't decide what to do. So let's think through this more now.
First of all, it's not a good idea to have a central place where all sources of navigation events from the entire app are directly imported and combined into a stream and passed into a declarative API. The reason is because many features need to be lazy-loaded, and importing the entire app in one place would undermine that.
Instead, something like the way declarative dialogs work might be a good idea: Yes, there is something central being controlled (the global modal element & backdrop), but it is easy to provide a wrapper component that can be used in any component, and pretend the modal is being opened right in the component triggering it:
<h1>Some Component Template</h1>
<dialog [open]="dialogIsOpen$ | async">
<app-dialog-content-component></app-dialog-content-component>
</dialog>
Could we do something like that for navigation? How's this:
<h1>Some Component Template</h1>
<button (click)="backClick$.next()">Back</button>
<app-navigate url="/" when="backClick$"></app-navigate>
You know what... That reminds me of something:
<a routerLink="/">Back</a>
So it turns out it is okay to navigate declaratively in the template! It's just that whenever we do it typically, there's a tight, closed loop from the click to the navigation. Is it okay to loosen that loop a little bit with that app-navigate
component?
Let's imagine we load a route, and then for some reason the app navigates away from it. Would it be easier to figure out why if the navigate
method was being called in the component class rather than by a component in the template?
Well, any component in the template might be calling the navigate
method internally anyway, since the router can be injected anywhere... And the fact that the wrapper component's selector would be app-navigate
makes it pretty obvious what's happening, doesn't it?
So, I am going to create this wrapper component and see how it goes. Here's the source code for it:
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import {
BehaviorSubject,
filter,
Observable,
of,
switchAll,
tap,
withLatestFrom,
} from 'rxjs';
@Component({
standalone: true,
selector: 'app-navigate',
template: '<ng-container *ngIf="navigate$ | async"></ng-container>',
imports: [RouterModule, CommonModule],
})
export class NavigateComponent {
@Input() set to(val: string) {
this.toInput$.next(val);
}
toInput$ = new BehaviorSubject<string>('');
@Input() set when(val: Observable<any>) {
this.whenInput$.next(val);
}
whenInput$ = new BehaviorSubject<Observable<any>>(of(null));
when$ = this.whenInput$.pipe(switchAll());
navigate$ = this.when$.pipe(
withLatestFrom(this.toInput$),
filter(([, url]) => url !== ''),
tap(([, url]) => this.router.navigate([url]))
);
constructor(private router: Router) {}
}
However, for this specific case, I don't see any reason I have to use this component. Instead of this data flow:
Why don't I just turn that back button into a link and have the data flow go like this?
Instead of reacting to backClick$
, we can have the other two methods react the the URL changing back to home
.
So how do we get those store methods to react?
NgRx/Component-Store
Our 2 store methods need to react to an observable, so we have 2 choices: Either we can convert the whole store to StateAdapt right now, or we could try a more incremental approach by implementing the reactive utilities I explained in this article first.
Let's do this incrementally.
You can see the source code for the reactive wrapper class I made here, but what matters is that it allows us to do this:
this.react<AppStore>(this, {
deleteMovies: urlFromMovieDetailToHome$.pipe(map(() => undefined)),
switchFlag: urlFromMovieDetailToHome$.pipe(map(() => false)),
});
This is made possible by directly importing ReactiveStore
and extending that class instead of ComponentStore
. Now, whenever urlFromMovieDetailToHome$
emits, it will trigger the methods on the left-hand side, and pass the emitted value into them.
In order to define urlFromMovieDetailToHome$
, I injected the router into the Component Store class and listened to the events for a specific transition:
const urlFromMovieDetailToHome$ = this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
pairwise(),
filter(
([before, after]) =>
before.url === '/movie' && ['/home', '/'].includes(after.url)
),
);
It's a bit complicated, but this will simplify a bit when we swap in StateAdapt.
But we now have update logic right in the store next to the state it's updating! Now, if we're ever wondering why movies were deleted, we can just look in the class and see how urlFromMovieDetailToHome$
is defined! Declarative code is nice. Granted, these update methods are technically imperative, but I think of the class as being one, self-contained thing, so this is close enough to declarative for me.
Takeaways from Event #1
Even though we only refactored one event to reactivity, it feels like we accomplished a lot already. So far, we have decided to
- navigate reactively by using a wrapper component for
router.navigate
- incrementally move NgRx/Component-Store to more reactive syntax
Finishing up the component
The remainder of the component is just a bunch of downstream RxJS stuff. As it's written, we have subscriptions inside subscriptions, and a lot of imperative statements:
cast!: any[];
movie!: MovieModel;
// ...
ngOnInit(): void {
this.store.state$.subscribe((res) => {
let movie = res.movieSelected;
if (movie == null) {
this.router.navigate(['/']);
} else {
this.singleMovie.getMovieDetails(movie.id).subscribe((data: any) => {
this.movie = data;
this.movie.poster_path = `${environment.imageUrl}${this.movie.poster_path}`;
});
this.singleMovie.getCast(movie.id).subscribe((data: any) => {
this.cast = Array.from(data.cast);
this.cast = this.cast.filter((c) => c.profile_path != null);
this.cast.forEach((c) => {
c.profile_path = `${environment.imageUrl}${c.profile_path}`;
});
});
}
});
}
Now that we have a declarative way to navigate, this doesn't look hard at all!
I found an observable on the store called movieSelected$
, so, I'll define two observables chaining off of that:
movieSelectedNull$ = this.store.movieSelected$.pipe(
filter((movie) => movie == null)
);
movieNotNull$ = this.store.movieSelected$.pipe(
filter((movie) => movie != null)
) as Observable<MovieModel>;
We'll feed movieSelectedNull$
into the template to trigger the navigation to home
:
<app-navigate to="/" [when]="movieSelectedNull$"></app-navigate>
Now we can use the non-null movie observable to define movie$
, the reactive replacement for the movie
property from the imperative implementation:
movie$ = this.movieNotNull$.pipe(
switchMap((movie) => this.singleMovie.getMovieDetails(movie.id)),
map((data: any) => ({
...data,
poster_path: `${environment.imageUrl}${data.poster_path}`,
}))
);
Finally, we can define cast$
, the reactive replacement for the cast
property from the imperative implementation:
cast$ = this.movie$.pipe(
switchMap((movie) =>
this.singleMovie.getCast(movie.id).pipe(
map((data: any) =>
(Array.from(data.cast) as any[])
.filter((c) => c.profile_path != null)
.map((c) => ({
...c,
profile_path: `${environment.imageUrl}${c.profile_path}`,
}))
)
)
)
);
And then we update the template, and we're done!
Conclusion
So far this process of starting with events and working downstream is going pretty smoothly.
Previously, there were 13 imperative statements, and lots of mixed concerns, as you can see from this color-coded screenshot:
A little bit of this was generic cleanup, but for the most part, reactivity simplified it a lot with better separation of concerns, and 0 imperative statements:
This looks like a lot less code, but remember that we moved a bunch over to the store, and added a little to the template. In total, the reactive implementation was actually 5 lines of code more. git stat
said the commit had 63 insertions and 58 deletions.
But the better separation of concerns is definitely worth it! And by the end of refactoring this app to reactivity, I suspect that the reactive implementation will actually be less code.
We'll see!
In the meantime, check out StateAdapt!
Top comments (3)
Too bad you cannot do array destructuring assignment to a class property. Otherwise you could use
partition()
with a single condition instead of the twofilter
operators to split theselectedMovie$
stream in anull
andnot null
stream.In the article below they do it in the constructor instead. Helps because you have to change the comparison only in one location. Also adds some more code because you have to declare the class properties separately.
scribe.rip/javascript-everyday/rxj...
Yeah, would be nice. If it was a more complicated comparison I would care more. Actually I talked about partition in the YouTube video I think
Yeah at 22:22 https://m.youtube.com/watch?v=EULYt4sHD1k&feature=youtu.be