I recently completed rewriting an application from imperative to fully reactive code. Here is the approach I used.
1. Starting with a single event handler
My starting point was one event and its handler. The handler was updating the URL along with two pieces of global state in NgRx/Component-Store. I swapped the (click) binding for a [routerLink] and made the state updates derive from the URL change:
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)
),
);
// ...
this.react<AppStore>(this, {
deleteMovies: urlFromMovieDetailToHome$.pipe(map(() => undefined)),
switchFlag: urlFromMovieDetailToHome$.pipe(map(() => false)),
});
That react method I wrote turned out to be redundant. I overlooked the part of the NgRx/Component-Store documentation which states that updaters can accept observables. So this would have sufficed:
this.deleteMovies(urlFromMovieDetailToHome$.pipe(map(() => undefined)));
this.switchFlag(urlFromMovieDetailToHome$.pipe(map(() => false)));
Moving on.
That URL-handling code I wrote is a bit odd, but it worked without issue. Looking back, it might have been an early indication that this particular state should have lived in the route rather than in a global store. However, I didn't act on that observation at the time, because other components were still modifying that state through their own handlers, and the setup was functioning well as it was.
2. Handling more events
I moved through the app component by component, converting each one to a reactive style. Coincidentally, each component had only a single event handler, which made the process equivalent to converting handler by handler. In hindsight, I believe a handler-focused migration is the better approach in general; you can always clean up the surrounding component code later.
After converting a few handlers, it became clear that there was nothing preventing me from removing some state from the global store and placing it right next to the feature that depended on it. I also noticed that several state slices were reacting to the exact same triggers, and some of them could actually be expressed as derived state—state that depends on and reacts to other upstream state. All of this becomes much easier to spot when the code controlling a given piece of state is located alongside it.
That's when the codebase started to get significantly simpler.
Once the final component had been converted to reactivity, I realized the global store had become unnecessary. I relocated all the state into the relevant feature modules, and the total codebase shrank by 32%.
There are surely many valid ways to carry out this kind of refactor, but here is the sequence I prefer:
- Pick an event handler to eliminate
- Remove it
- Have the event cause a single, direct change instead
- Make downstream state updates react to that one change
- Identify the state slice with the fewest remaining handlers, relocate it, and repeat steps 2–4
- Once a piece of state no longer has any handlers, move it as close as possible to its consuming features
- Repeat steps 1–6 for each feature that depends on the newly reactive state, until all features are fully reactive
3. Wrapping imperative APIs
Certain features expose imperative APIs. To eliminate imperative code entirely, you have to wrap those APIs behind declarative ones.
In the first two articles of this series, I covered how I wrapped two of these imperative APIs.
The first was router.navigate. I built a wrapper component whose usage looks like this:
<app-navigate [url]="url$ | async"></app-navigate>
The second was a Sweet Alerts wrapper component, usable like so:
<app-swal
[options]="{}"
[show]="alertIsVisible$ | async"
(close)="alertClosed$.next()"
></app-swal>
There was also a third imperative API I hadn't previously written about: the image carousel. Previously, it was controlled in this manner:
@ViewChild('carousel', { static: true }) carousel!: NgbCarousel;
// ...
togglePaused() {
if (this.paused) {
this.carousel.cycle();
} else {
this.carousel.pause();
}
this.paused = !this.paused;
}
The insight was that I could simply adjust the interval at which it loops (an idea I originally got from Josh Moroney, which I find quite clever):
pausedAdapter = buildAdapter<boolean>()(booleanAdapter)({
interval: (s) => (s.state ? 9999999 : this.config.interval),
})();
This uses StateAdapt syntax, by the way. The interval value becomes available as an observable on the store built with the pausedAdapter. Here is the store definition:
paused = adapt(['carousel.paused', false, this.pausedAdapter], {
setFalse: this.arrow$,
setTrue: this.indicator$,
});
And here it is used in the template:
[interval]="(paused.interval$ | async) || config.interval"
Relying on declarative APIs for these features was a great relief. I was tired of breaking up clean RxJS streams just to sprinkle in manual subscriptions for imperative calls. Let's change that!
Diagrams
Below are diagrams illustrating the data flow before and after the refactor. For a step-by-step walkthrough of each diagram, check out the YouTube video that follows.
Thanks for reading! If you haven't already, take a look at StateAdapt. Version 1.0 is on the horizon, expected within the next month!
