From Imperative Callbacks to Reactive Streams
Most Angular applications follow a familiar pattern: events fire, handlers run, and imperative code executes inside them. Take a typical submit handler, for example:
onSubmit() {
if (this.form.valid) {
this.store.deleteMovies();
this.headerService
.searchMovies(this.form.value)
.subscribe((data: any) => {
// ...
this.store.saveSearch(data.results);
this.store.saveSearchHeader(this.form.value);
this.store.switchFlag(true);
this.router.navigate(['/search']);
});
} else {
swal({ // toast
title: 'Incorrecto',
// ...
});
}
}
}
That single callback touches five distinct areas of state:
store.searchstore.headerstore.flag- the application's URL
- the
invalidtoast
This approach scatters the logic for each of those five state pieces across numerous callbacks like onSubmit. Understanding why something looks the way it does at any moment means jumping between many places in the codebase. Reliance on "Find All References" becomes a daily annoyance.
Removing event handlers altogether seems like a reasonable first step toward a more reactive architecture.
What would that reactive version even look like?
A straightforward principle guides reactivity: every user interaction in the template should push the smallest possible change to one location in the TypeScript, and everything else responds to that change. The diagram below illustrates this idea:
The code sitting immediately downstream from the event source becomes simpler, and anything that can derive from something else does. This maximizes reactivity. Generally speaking, diagrams of reactive data flows tend to be taller and narrower than their imperative counterparts. The improved separation of concerns is visually apparent.
(I haven't verified this yet, but store.header and store.flag might both be downstream from store.searchResults.)
The logic governing each piece of state now lives right next to that state. Writing new state-management code becomes easier and less error-prone because similar logic is immediately at hand for reference.
Building the Reactive Version
Start by creating a subject that represents form submission:
search$ = new Subject<[boolean, string]>();
<input
type="submit"
value="Buscar"
class="submit"
(click)="search$.next([form.valid, form.value.search])"
/>
Why include a payload when the original onSubmit handler accepted no arguments?
Why Event Context Matters
The rationale for passing form data into the search$ event is adaptability. The original handler depended on form.valid and form.value, but referenced them implicitly. That implicit coupling makes it awkward to relocate the logic into a service or elsewhere.
A common objection to reactive state management libraries like StateAdapt or NgRx is that state gets tucked into isolated slices, reducers, or stores. Sometimes calculating a state change requires knowledge held by another reducer or store. The tempting solution is to consolidate everything into one massive reducer, but that defeats the purpose.
The elegant answer is to attach context directly to the event that triggers the state change.
Handling Navigation
Two pieces of state now sit immediately downstream of search$:
Just like in the earlier component, the stream needs to split into two branches. Using filter again rather than partition.
It's much more ergonomic to supply a URL string and let an app-navigate component react to it. The previous article introduced an API that was less than optimal. Instead of maintaining a separate observable that triggers router.navigate, the mindset shifts to being state-centric: the router wrapper's job is to keep the browser URL synchronized with the url input it receives.
Here's how the observables chaining off search$ are defined:
search$ = new Subject<[boolean, string]>();
searchIsInvalid$ = this.search$.pipe(map(([valid]) => !valid));
url$ = this.search$.pipe(
filter(([valid]) => valid),
map(([, search]) => `search/${search}`)
);
The url$ observable feeds directly into the app-navigate component:
<app-navigate [url]="url$ | async"></app-navigate>
The updated component source looks like this:
import { Component, Input, SimpleChanges } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
@Component({
standalone: true,
selector: 'app-navigate',
template: '',
imports: [RouterModule],
})
export class NavigateComponent {
@Input() url: string | null = null;
constructor(private router: Router) {}
ngOnChanges(changes: SimpleChanges) {
const urlChange = changes['url'];
const newUrl = urlChange?.currentValue;
if (newUrl) {
this.router.navigate([newUrl]);
}
}
}
The result is smaller and cleaner than before. That's a win.
The component from the previous article now maps to a URL instead of selectedMovieNull$:
url$ = this.store.movieSelected$.pipe(
filter((movie) => movie == null),
map(() => '/')
);
A route parameter was added to the search route, since the search term is now tracked there.
Interestingly, the store.header state turned out to be the search query string itself. It's been folded into the URL, and it can be removed from the store. Nothing was using it anyway. Refactoring random projects from GitHub is always an adventure.
Making Toasts Declarative
It turns out swal abbreviates "Sweet Alert", and this is a modal being opened rather than a toast. The distinction hardly matters though. Everything in the UI should be declarative, and this could just as easily be a toast.
A wrapper component can be created to pass into the swal function call:
import { Component, Input, SimpleChanges } from '@angular/core';
import { SwalParams } from 'sweetalert/typings/core';
import swal from 'sweetalert';
@Component({
standalone: true,
selector: 'app-swal',
template: '',
})
export class SwalComponent {
@Input() show: boolean | null = false;
@Input() options: SwalParams[0] = {};
ngOnChanges(changes: SimpleChanges) {
const showChange = changes['show'];
const newShow = showChange?.currentValue;
if (newShow) {
swal(this.options);
}
}
}
This design can certainly evolve, but it's sufficient for current purposes.
The template can now use it, and the search/header component is fully converted to reactivity:
<app-swal
[options]="{
title: 'Incorrecto',
text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
icon: 'warning',
dangerMode: true
}"
[show]="searchIsInvalid$ | async"
></app-swal>
There's a bug here: the modal opens once but never again. The state fed into the swal wrapper never resets, so ngOnChanges stops firing.
To make this work, a callback from the swal alert needs to update an observable that gets merged back into its input, effectively passing a false value again.
Implementing that now. Here's the revised swal wrapper:
import {
Component,
EventEmitter,
Input,
Output,
SimpleChanges,
} from '@angular/core';
import { SwalParams } from 'sweetalert/typings/core';
import swal from 'sweetalert';
@Component({
standalone: true,
selector: 'app-swal',
template: '',
})
export class SwalComponent {
@Input() show: boolean | null = false;
@Input() options: SwalParams[0] = {};
@Output() close = new EventEmitter<any>();
ngOnChanges(changes: SimpleChanges) {
const showChange = changes['show'];
const newShow = showChange?.currentValue;
if (newShow) {
swal(this.options).then((value) => this.close.emit(value));
}
}
}
The state passed in needs to always reflect whether the alert is currently open.
The simplest way to track dialog state here is StateAdapt, because it needs to react to the searchIsInvalid$ observable while also being settable directly from the template. Some imperative action from the template is unavoidable, so being direct about it is best.
First, AppModule needs this setup:
import { defaultStoreProvider } from '@state-adapt/angular';
// ...
providers: [defaultStoreProvider],
Inside the component, it's used like this:
import { booleanAdapter } from '@state-adapt/core/adapters';
import { toSource } from '@state-adapt/rxjs';
import { adapt } from '@state-adapt/angular';
// ...
searchIsInvalid$ = this.search$.pipe(
map(([valid]) => !valid),
toSource('searchIsInvalid$')
);
invalidAlertOpen = adapt(
['invalidAlertOpen', false, booleanAdapter],
this.searchIsInvalid$
);
<app-swal
[options]="{
title: 'Incorrecto',
text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
icon: 'warning',
dangerMode: true
}"
[show]="invalidAlertOpen.state$ | async"
(close)="invalidAlertOpen.setFalse()"
></app-swal>
As a bonus, this state now appears in Redux Devtools:
Oddly enough, the first piece of state tracked in Redux Devtools for this entire application is an alert dialog that wasn't previously managed as state at all.
Changes Further Downstream
The component is working. Time to move to the next level down the stream:
These changes all belong in app.store.ts, since that's what they affect.
First, trigger deleteMovies when the route is first entered. Some existing logic gets split out so it can be reused. The current implementation:
// New
const urlAfterNav$ = this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => event.url)
);
// From before:
const urlFromMovieDetailToHome$ = urlAfterNav$.pipe(
pairwise(),
filter(
([before, after]) =>
before === '/movie' && ['/home', '/'].includes(after)
)
);
// New
const searchRoute$ = urlAfterNav$.pipe(
filter((url) => url.startsWith('/search'))
);
There's a bug: switchFlag also needs to be called immediately. Both state reactions can be triggered with this new observable:
this.react<AppStore>(this, {
deleteMovies: merge(urlFromMovieDetailToHome$, searchRoute$).pipe(
map(() => undefined)
),
switchFlag: merge(
urlFromMovieDetailToHome$.pipe(map(() => false)),
searchRoute$.pipe(map(() => true))
),
});
If this block is confusing, check the previous article for an explanation of react. The key idea is that the keys of the passed object correspond to state change functions in the NgRx/Component-Store, and those functions get invoked with whatever the observables on the right-hand side emit.
The results$ observable chains off the search term in the URL:
const searchResults$ = searchRoute$.pipe(
switchMap((url) => {
const [, search] = url.split('/search/');
return this.headerService.searchMovies({ search });
}),
map((res: any) =>
res.results.map((movie: any) => ({
...movie,
poster_path:
movie.poster_path !== null
? `${environment.imageUrl}${movie.poster_path}`
: 'assets/no-image.png',
}))
)
);
This takes the URL, extracts the search query, passes it to the MovieService, and maps over the results to assign a default poster image. That logic existed before, including the unfortunate any types. No time to clean those up right now.
That observable plugs into the react method:
saveSearch: searchResults$,
saveSearch could arguably be named receiveSearchResults, but that will be dealt with later.
Final Thoughts
Everything works again, and it's fully reactive. The final commit is here.
The most important lesson comes from building SwalComponent and refactoring AppNavigateComponent: Angular inputs should represent state, not events. That's a more declarative approach anyway, and the wrapper components are much better for it.
Building these wrappers and integrating them properly does take effort. In return, the data flow becomes far more flexible with better separation of concerns.
Before, there were 11 imperative statements and a tangle of mixed concerns. This screenshot shows the old state:
The new reactive implementation has only 2 imperative statements, both coming from the template, which is unavoidable. But the reactive data flow clearly improves separation of concerns:
Notice how multiple sources of state changes now converge in one place, including the new state changes introduced during this refactor. At the end of this project, it might be interesting to visualize how state change logic across multiple files became centralized next to the state it controls.
Much of the code created during this refactor isn't shown explicitly; it's represented through variable names instead. Take this line:
saveSearch: searchResults$,
It's clear that state will change as described by saveSearch whenever searchResults$ emits. The name searchResults$ conveys the essential meaning. For more detail, "Click to Definition" will reveal the implementation.
Everything that updates search results lives right here. Right now it's the only source. Adding another would look something like:
saveSearch: merge(searchResults$, moreSearchResults$),
In an imperative project, figuring out why search results changed means using Find All References on the saveSearch method. You can't even know which callback functions control it without doing that.
Beyond that, a callback named onSubmit gives no hint about what it does or what state it changes. It's a poor function name. Ideally the template would say something like (submit)="saveToServer()", but what if multiple things need to happen? Callback functions, being containers of imperative code, can only be named for the common thread tying their effects together. All too often, the only available name is the event or context where the callback fires. That flies in the face of Clean Code advice that functions should be named for what they do.
Declarative programming produces good names more naturally, because each named element concerns itself only with its own task.
How much code did this take? git diff --stat reports 79 insertions and 62 deletions.
It's looking questionable whether total code will shrink by the end. The change from
swal({
title: 'Incorrecto',
text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
icon: 'warning',
dangerMode: true,
});
to
searchIsInvalid$ = this.search$.pipe(
map(([valid]) => !valid),
toSource('searchIsInvalid$')
);
invalidAlertOpen = adapt(
['invalidAlertOpen', false, booleanAdapter],
this.searchIsInvalid$
);
and
<app-swal
[options]="{
title: 'Incorrecto',
text: 'Debes ingresar al menos dos caracteres para hacer una búsqueda..',
icon: 'warning',
dangerMode: true
}"
[show]="invalidAlertOpen.state$ | async"
(close)="invalidAlertOpen.setFalse()"
></app-swal>
jumped from 6 to 18 lines, which accounts for most of the difference.
But given that something now shows up in Redux Devtools, and the dialog's concern is separated from the other updates onSubmit handled, the trade-off seems worthwhile.
Time will tell how the totals compare. There are still 2-3 components awaiting refactoring.
