In this article, I will demonstrate how to manage your application’s state using only Angular Signals and a small function.
More than “Service with a Subject”
Let’s begin with an explanation of why using a bunch of BehaviorSubject objects inside a service is not enough to manage state modifications caused by asynchronous events.
In the code below, we have a method saveItems()
that will call the API service, to update the list of items asynchronously:
saveItems(items: Item[]) {
this.apiService.saveItems(items).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((items) => this.items$.next(items));
}
Every time we call this method, we are taking a risk.
Example: Let’s say we have two requests, A and B.
Request A started at time 0s 0ms, and Request B started at 0s 250ms. However, due to some issue, the API responded to A after 500ms, and to B after 150ms.
As a result, A was completed at 0s 500ms, and B at 0s 400ms.
This can lead to the wrong set of items being saved.
It also works with GET requests — sometimes it’s pretty important, what filter should be applied to your search request.
We could add some check like this:
saveItems(items: Item[]) {
if (this.isSaving) {
return;
}
this.isSaving = true;
this.apiService.saveItems(items).pipe(
finalize(() => this.isSaving = false),
takeUntilDestroyed(this.destroyRef)
).subscribe((items) => this.items$.next(items));
}
But then the correct set of items will have no chance to be saved at all.
That’s why we need effects in our stores.
Using NgRx ComponentStore, we could write this:
readonly saveItems = this.effect- (_ => _.pipe(
concatMap((items) => this.apiService.saveItems(items)),
tapResponse(
(items)=> this.items$.next(items),
(err) => this.notify.error(err)
)
));
Here you can be sure that requests will be executed one after another, no matter how long each of them will run.
And here you can easily pick a strategy for request queuing: switchMap()
, concatMap()
, exhaustMap()
, or mergeMap()
.
Signal-based Store
What is an Application State? An Application State is a collection of variables that define how the application should look and behave.
An application always has some state, and Angular Signals always have a value. It’s a perfect match, so let’s use signals to keep the state of our application and components.
class App {
$users = signal([]);
$loadingUsers = signal(false);
$darkMode = signal(undefined);
}
It is a simple concept, but there is one issue: anyone can write to $loadingUsers
. Let’s make our state read-only to avoid infinite spinners and other bugs that globally writable variables can bring:
class App {
private readonly state = {
$users: signal([]),
$loadingUsers: signal(false),
$darkMode: signal(undefined),
} as const; readonly $users = this.state.$users.asReadonly();
readonly $loadingUsers = this.state.$loadingUsers.asReadonly();
readonly $darkMode = this.state.$darkMode.asReadonly();
setDarkMode(dark: boolean) {
this.state.$darkMode.set(!!dark);
}
}
Yes, we wrote more lines; otherwise, we would have to use getters and setters, and it’s even more lines. No, we can not just leave them all writeable and add some comment “DO NOT WRITE!!!” 😉
In this store, our read-only signals (including signals, created using computed()
) are the replacement for both: state and selectors.
The only thing left: we need effects, to mutate our state.
There is a function in Angular Signals, named effect()
, but it only reacts to the changes in signals, and pretty often we should modify the state after some request(s) to the API, or as a reaction to some asynchronously emitted event. While we could use toSignal()
to create additional fields and then watch these signals in Angular’s effect()
, it still wouldn’t give us as much control over asynchronous code as we want (no switchMap()
, no concatMap()
, no debounceTime()
, and many other things).
But let’s take a well-known, well-tested function, with an awesome and powerful API: ComponentStore.effect()
and make it standalone!
createEffect()
Using this link, you can get the code of the modified function. It’s short, but don’t worry if you can’t understand how it works under the hood (it takes some time): you can read the documentation on how to use the original effect()
method here: NgRx Docs, and use createEffect()
the same way.
Without typing annotations, it is pretty small:
function createEffect(generator) {
const destroyRef = inject(DestroyRef);
const origin$ = new S