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:
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 event callback is controlling 5 pieces of state:
store.search
store.header
store.flag
- the app's URL
- the
invalid
toast
This isn't great separation of concerns, because logic that controls each of those 5 pieces of state is scattered across many callbacks such as onSubmit
, 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:
Not only is the code immediately downstream from the event source much simpler, but everything that can be downstream from something else is. This maximizes reactivity. In general, diagrams of reactive data flows will be taller and skinnier than diagrams of imperative data flows. You can actually see the improved separation of concerns.
(I haven't looked into this yet, but I suspect that even store.header
and store.flag
might be downstream from store.searchResults
.)
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
First, let's say we create a subject to represent the form submission:
search$ = new Subject<[boolean, string]>();
<input
type="submit"
value="Buscar"
class="submit"
(click)="search$.next([form.valid, form.value.search])"
/>
Why did I give it a payload whereas the previous event handler onSubmit
took no parameters?
Event Context
The Reason to pass the form data into the search$
event is flexibility: The previous event handler actually needed form.valid
and form.value
; it was just implicitly referenced. This makes it require refactoring if a developer wanted to move it to a service or something.
One problem people often have with reactive state management like StateAdapt or NgRx is that state is siloed into independent slices (or reducers, or stores). Sometimes in order to calculate a state change, you need to know the state of another state reducer/store. So what do you do? Refactor everything into one giant reducer/store?
The simple answer is to provide context with the event that is causing the state change.
Navigation
We now have 2 pieces of state immediately downstream from search$
:
Like in the previous component, we need to split our stream into 2 pieces again. Again, I'll just use filter
instead of partition
.
Also... It's much more convenient to pass in a URL string and have app-navigate
just react to that. In the last article I was thinking through this for the first time, and I believe I came up with an API that is less than ideal. So, rather than having a separate observable to trigger the router.navigate
call, the mindset will be state-centric: The job of the router wrapper component is to ensure that the browser URL is in sync with the url
input passed into it.
First, here's how I define the observables that chain off of search$
:
search$ = new Subject<[boolean, string]>();
searchIsInvalid$ = this.search$.pipe(map(([valid]) => !valid));
url$ = this.search$.pipe(
filter(([valid]) => valid),
map(([, search]) => `search/${search}`)
);
Now I can feed url$
into the app-navigate
component:
<app-navigate [url]="url$ | async"></app-navigate>
Also, here's the new source code for that component:
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]);
}
}
}
That's actually smaller and simpler than it used to be. I like it.
Now I'll refactor the component from the last article to map to a URL instead of selectedMovieNull$
:
url$ = this.store.movieSelected$.pipe(
filter((movie) => movie == null),
map(() => '/')
);
I added a route parameter to the search
route, since search is being tracked there now.
And I just discovered that the store.header
state was the search query string. So apparently I have merged it in with the URL, and I can get rid of it in the store. It actually was never being used anywhere anyway, it turns out. I love refactoring random projects from GitHub :)
Toasts
I'm only now realizing that swal
actually means "Sweet Alert" and this is a modal being opened, not a toast. But it doesn't matter. Everything in the UI should be declarative, and this might as well be a toast.
So let's make a wrapper component we can pass into a 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 could evolve over time, but this is good enough for our purposes.
Now I can use it in the template and the search/header component is completely 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>
This has a bug apparently. It only opens once, then never opens again. This is because we are not resetting the state we're passing into the swal
wrapper component, so ngOnChanges
is not reacting to it.
In order for this to work correctly, we'd need to handle a callback function from the swal
alert and have it update an observable to be merged back into its input so a false
value would be passed in.
I decided to go ahead and implement this. Here's the new swal
wrapper component:
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));
}
}
}
Now we need to change the state we pass in to always reflect the state of whether that alert is open.
In this case, the simplest way of tracking the dialog state I can think of is using StateAdapt. This is because it needs to react to an observable searchIsInvalid$
but also be able to be set directly from the template. Remember, from the template, something imperative always needs to happen anyway, so we might as well be direct with it.
To use StateAdapt, we first need this in our AppModule
:
import { defaultStoreProvider } from '@state-adapt/angular';
// ...
providers: [defaultStoreProvider],
Now we can use it in the component 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>
And as an added benefit, we get this state tracked in Redux Devtools:
How weird is that? The first state tracked in Redux Devtools in this entire app is an alert dialog, and it wasn't even managed as state before.
Downstream NgRx/Component-Store Changes
Alright, the component is working great, now we need to move on to the next level downstream:
This is all going to be defined in app.store.ts
, because that's what it affects.
First let's trigger deleteMovies
when the route is first entered. There's some logic we previously defined that I'm going to split out so we can reuse it. Here's what I have now:
// 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'))
);
Nice.
I'm getting a bug as well. It turns out switchFlag
needs to be called immediately too. So, I'm going to trigger both state reactions 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 you're confused about this block of code, see the last article where I explained react
. Hopefully it's somewhat self-explanatory, but just know that the keys of the object passed in are names of state change functions in this NgRx/Component-Store, and they get called and passed the values emitted from the observables on the right-hand side.
Now we just need to define the results$
observable as chaining off of 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, grabs the search query from it, passes it to the MovieService
and loops through the results giving each movie a default poster image. This logic existed already, including those unfortunate any
types. I don't feel like fixing that right now.
And we can plug this observable into the react
method now:
saveSearch: searchResults$,
saveSearch
should probably be named receiveSearchResults
, but I'm going to ignore that for now too.
Conclusion
Everything works again! And it's reactive now! Here's the final commit.
The main takeaway from this is the lesson I learned from creating the SwalComponent
and refactoring the AppNavigateComponent
: Angular inputs should represent state, not events. This is more declarative anyway, so I'm happy with the way these wrapper components are now.
Yes, it does take work to create these wrapper components and integrate them properly. But it enables a much more flexible data flow with better separation of concerns.
Previously, there were 11 imperative statements, and lots of mixed concerns, as you can see from this screenshot:
The new reactive implementation only has 2 imperative statements, both from the template, so, unavoidable. But the reactive data flow introduced better separation of concerns, as you can see:
Notice here that we now have multiple sources of state changes in one place, including the state changes we added when refactoring the first component. At the end of this refactor project, maybe I should come up with a way to show visually how across multiple files, state change logic became centralized next to the state it was changing. That might be cool.
Another thing to notice is that I'm not showing all the code I created while refactoring. That is because it is represented in the variable name instead. If you look at just this line:
saveSearch: searchResults$,
You know that the state will change as described by saveSearch
when searchResults$
emits. The name searchResults$
tells you what you need to know, and if you want to know more details, you can use "Click to Definition" and look at it.
Everything that will cause search results to be updated will be right here. So far this is the only one. But if we add another, it might look like
saveSearch: merge(searchResults$, moreSearchResults$),
On the other hand, the only way to know why search results changed in an imperative project would be to use Find All References
on the saveSearch
method. You don't even know the names of the callback functions that are controlling it until you do that.
Not only that, but a callback function like onSubmit
gives you no clue what it actually does or what state it modifies. It's a bad name for a function; ideally you could have something in the template like (submit)="saveToServer()"
, but what if that's not the only thing that has to happen? With callback functions, which are containers of imperative code, you can only give it the name of the commonality of all of its effects. It happens to be that quite often the only name people can come up with is the circumstance or context in which the callback function is called. This violates Clean Code's advice for good function names, which says that functions should be named for what they do.
Good names come more easily with declarative programming, because the things you have to name are only concerned with themselves.
So, how much code was that? git diff --stat
says 79 insertions and 62 deletions.
I'm beginning to doubt that this will be less code by the end. I increased the lines of code a lot when I changed
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>
That's going from 6 to 18 lines of code. That's most of the difference, actually.
But since we now have something in Redux Devtools, and we separated the concern of the dialog from the other updates that onSubmit
was burdened with, I think this was worth it.
I guess we'll see how things compare in the end! There are still 2-3 components to refactor.
Top comments (0)