DEV Community

Cover image for Pushing Angular's Reactive Limits: Router and Component Store
Mike Pearson for This is Angular

Posted on

Pushing Angular's Reactive Limits: Router and Component Store

YouTube

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(['/']);
  }
Enter fullscreen mode Exit fullscreen mode

That event callback is controlling 3 pieces of state: store.movies, store.flag and the app's URL.

Imperative Callback

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:

Reactive Source Diagram

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>();
Enter fullscreen mode Exit fullscreen mode

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(['/']);
Enter fullscreen mode Exit fullscreen mode

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(['/']);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

You know what... That reminds me of something:

<a routerLink="/">Back</a>
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

However, for this specific case, I don't see any reason I have to use this component. Instead of this data flow:

Reactive Source Diagram

Why don't I just turn that back button into a link and have the data flow go like this?

Reactive Source 2 Diagram

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)),
    });
Enter fullscreen mode Exit fullscreen mode

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)
      ),
    );
Enter fullscreen mode Exit fullscreen mode

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

  1. navigate reactively by using a wrapper component for router.navigate
  2. 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}`;
          });
        });
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

We'll feed movieSelectedNull$ into the template to trigger the navigation to home:

<app-navigate to="/" [when]="movieSelectedNull$"></app-navigate>
Enter fullscreen mode Exit fullscreen mode

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}`,
    }))
  );
Enter fullscreen mode Exit fullscreen mode

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}`,
            }))
        )
      )
    )
  );
Enter fullscreen mode Exit fullscreen mode

And then we update the template, and we're done!

Here's the full commit.

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:

info-movie.component-before

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:

info-movie.component-after

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!

Oldest comments (3)

Collapse
 
wilgert profile image
Wilgert Velinga

Too bad you cannot do array destructuring assignment to a class property. Otherwise you could use partition() with a single condition instead of the two filter operators to split the selectedMovie$ stream in a null and not 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...

Collapse
 
mfp22 profile image
Mike Pearson

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

Collapse
 
mfp22 profile image
Mike Pearson