Using Angular Signals for Global State
Jamie Nordmeyer
Posted on July 29, 2023
With the release of Angular 16 we got access to the new Signals API, along with a host of other features. They are currently in developer preview meaning that they could technically change between now and the next release of Angular. However, you can use them now, and they provide a much cleaner way to have reactive code in your web application. They DO not replace RxJS Observables; Angular services like HttpClient and resolvers still rely on RxJS. However, they do provide another tool in the box for responding to changes in your application that are often easier to understand than the RxJS alternative.
Code With RxJS
In an application that I’m working on, before Angular 16 shipped, I was using a custom StoreService
to hold global application state. I’ve tried libraries like NgRx and Akita to manage global state, but found them to be way too heavy-handed for what I wanted (not saying ANYTHING negative towards these libraries; not every tool is right for every job, and the authors of these libraries would probably be the first to tell you that). This custom StoreService
was created using RxJS, and looked like this:
import { Injectable } from '@angular/core';
import { ApplicationState } from '@shared/models';
import { UserCard } from '@shared/models';
import { BehaviorSubject, map } from 'rxjs';
const initialState: ApplicationState = {
userCard: null,
};
@Injectable({
providedIn: 'root',
})
export class StoreService {
private readonly store$ = new BehaviorSubject<ApplicationState>(initialState);
readonly userCard$ = this.store$.pipe(map((state) => state.userCard));
setUserCard(userCard: UserCard | null) {
this.store$.next({
...this.store$.value,
userCard: userCard,
});
}
}
Here, the UserCard interface represents the basic details of the logged-in user, things like name and data points for putting together a URL to their Avatar image. When I wanted to retrieve the user card for the logged-in user, a service would be used:
import { Inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, mergeMap, Observable, of, tap } from 'rxjs';
import { StoreService } from '@shared/services';
import { UserCard } from '@shared/models';
@Injectable({
providedIn: 'root',
})
export class UserCardService {
constructor(
private http: HttpClient,
private storeService: StoreService,
@Inject('API_BASE_URL') private baseUrl: string
) {}
getUserCard(): Observable<UserCard | null> {
return this.storeService.userCard$.pipe(
mergeMap((existingUserCard) =>
existingUserCard
? of(existingUserCard)
: this.http.get<UserCard>(`${this.baseUrl}v1/users/user-card`).pipe(
tap((userCard) => this.storeService.setUserCard(userCard)),
catchError(() => {
this.storeService.setUserCard(null);
return of(null);
})
)
)
);
}
}
In the getUserCard
method on this service, I first check to see if the StoreService
has a previously loaded UserCard instance. If it does, I just return that. However, if the StoreService
does not, it will make an HTPP call to retrieve the user card from the server, store that in the StoreService
, and then return the user card.
For the component that displays the user card data, I would expose properties like this as public properties on the component:
get avatarUrl$(): Observable<string | null> {
return this.storeService.userCard$.pipe(
map((uc) =>
uc?.avatarVersionKey && uc.avatarVersionKey !== 0
? `${this.apiBaseUrl}v1/users/${uc?.uniqueKey}/avatar?size=64&v=${uc?.avatarVersionKey}`
: `/assets/images/avatar_small.png`
)
);
}
get userFullName$(): Observable<string | null> {
return this.storeService.userCard$.pipe(
map((siu) => (siu ? `${siu.givenName} ${siu.surName}` : ''))
);
}
Then in the HTML I need to use the async
pipe to get the values out:
<span class="name">{{ userFullName$ | async }}</span>
Coding With Signals
This of course works, and it’s what we’ve been doing for years in Angular. However, now that Signals have arrived, this code can be greatly simplified. Let’s start with the Signals-based implementation of the StoreService
class.
import { Injectable, computed, signal } from '@angular/core';
import { ApplicationState } from '@shared/models';
import { UserCard } from '@shared/models';
const initialState: ApplicationState = {
userCard: null,
};
@Injectable({
providedIn: 'root',
})
export class StoreService {
private readonly _store = signal(initialState);
readonly userCard = computed(() => this._store().userCard);
setUserCard(userCard: UserCard | null) {
this._store.update((s) => ({ ...s, userCard: userCard }));
}
}
So far, it’s not a ton smaller or simplified from the RxJS version. Instead of a BehaviorSubject
instance, I’m using a signal. The initial state is still being passed in to initialize the signal. I then define the userCard
value to be a computed
value. This creates a new signal, based on the _store
signal in this case, that will automatically notify all of its listeners whenever the _store
signal is updated, and return just the UserCard
instance.
When calling setUserCard
, I don’t need to call a next$
method. Calling next$
is an RxJS thing, and at least for me, it’s counter-intuitive. You have to remember that Observable is a stream of events and then next$
makes sense. However, with signals, I’m calling update
, which feels more natural. The update
method passes the current value of the signal to an arrow function, which I’m then spreading into a new object, and replacing the userCard value with the new UserCard
. Currently, my state ONLY has the UserCard
field, but as it starts to expand to hold other global state, then this becomes more useful.
The real “Oh…” moment for the cleanliness of the code for me comes in the new implementation of the UserCardService
class:
import { Inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, Observable, of, tap } from 'rxjs';
import { StoreService } from '@shared/services';
import { UserCard } from '@shared/models';
@Injectable({
providedIn: 'root',
})
export class UserCardService {
constructor(
private http: HttpClient,
private storeService: StoreService,
@Inject('API_BASE_URL') private baseUrl: string
) {}
getUserCard(): Observable<UserCard | null> {
const uc = this.storeService.userCard();
if (uc) return of(uc);
return this.http.get<UserCard>(`${this.baseUrl}v1/users/user-card`).pipe(
tap((userCard) => this.storeService.setUserCard(userCard)),
catchError(() => {
this.storeService.setUserCard(null);
return of(null);
})
);
}
}
The getUserCard
method still returns an Observable, as the resolver consuming it wants an observable. However, the implementation is much easier to understand in my opinion. I can execute the userCard
method off the StoreService
to get the current user card if there is one, and because it’s just a method, not an RxJS Observable, I can just return it straight away if I already have it; no need for mergeMap
. I then call the HTTP endpoint like before, and add the user card to the StoreService
.
The component fields are also greatly simplified (it’s really nice not having to use the RxJS pipe
function):
avatarUrl = computed(() => {
const uc = this.storeService.userCard();
return uc?.avatarVersionKey && uc.avatarVersionKey !== 0
? `${this.apiBaseUrl}v1/users/${uc?.uniqueKey}/avatar?size=64&v=${uc?.avatarVersionKey}`
: `/assets/images/avatar_small.png`;
});
userFullName = computed(() => {
const uc = this.storeService.userCard();
return uc ? `${uc.givenName} ${uc.surName}` : '';
});
And now, in the HTML, we no longer need the async
pipe. Instead, since each of these properties are signals, we access them like methods in the HTML:
<span class="name">{{ userFullName() }}</span>
Final Thoughts
I really like the new Signals API, and am looking forward to seeing where else the Angular team takes it. Will the HttpClient
class eventually use Signals (maybe via a new class called HttpSignalsClient
to maintain backward compatibility)? Will there be new constructs for our HTML templates that are Signals aware? Only time will tell. But so far, I really like what I’m seeing.
Posted on July 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024