Application State Management with Angular Signals
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<Item[]>(_ => _.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<User[]>([]);
$loadingUsers = signal<boolean>(false);
$darkMode = signal<boolean|undefined>(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<User[]>([]),
$loadingUsers: signal<boolean>(false),
$darkMode: signal<boolean|undefined>(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 Subject();
generator(origin$).pipe(
retry(),
takeUntilDestroyed(destroyRef)
).subscribe();
return ((observableOrValue) => {
const observable$ = isObservable(observableOrValue)
? observableOrValue.pipe(retry())
: of(observableOrValue);
return observable$.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => {
origin$.next(value);
});
});
}
It was named createEffect()
to donāt interfere with Angularās effect()
function.
Modifications:
createEffect()
is a standalone function. Under the hood, it subscribes to an observable, and because of thatcreateEffect()
can only be called in an injection context. Thatās exactly how we were using the originaleffect()
method;createEffect()
function will resubscribe on errors, which means that it will not break if you forget to addcatchError()
to your API request.
And, of course, feel free to add your modifications :)
Put this function somewhere in your project, and now you can manage the application state without any additional libraries: Angular Signals + createEffect()
.
Update: now you can get this function in a tree-shakeable way from ngxtension!
Store Types
There are three types of stores:
- Global Store (application level) ā accessible to every component and service in your application;
- Feature Store (āfeatureā level) ā accessible to the descendants of some particular feature;
- Local Store (a.k.a āComponent Storeā) ā not shared, every component creates a new instance, and this instance will be destroyed when the component is destroyed.
I wrote an example application to show you how to implement a store of every type using Angular Signals and createEffect()
. Iāll use stores and components (without templates) from that application to let you see the code examples in this article. The whole code of this app you can find here: GitHub link.
Global Store
@Injectable({ providedIn: 'root' })
export class AppStore {
private readonly state = {
$planes: signal<Item[]>([]),
$ships: signal<Item[]>([]),
$loadingPlanes: signal<boolean>(false),
$loadingShips: signal<boolean>(false),
} as const;
public readonly $planes = this.state.$planes.asReadonly();
public readonly $ships = this.state.$ships.asReadonly();
public readonly $loadingPlanes = this.state.$loadingPlanes.asReadonly();
public readonly $loadingShips = this.state.$loadingShips.asReadonly();
public readonly $loading = computed(() => this.$loadingPlanes() || this.$loadingShips());
constructor() {
this.generateAll();
}
generateAll() {
this.generatePlanes();
this.generateShips();
}
private generatePlanes = createEffect(_ => _.pipe(
concatMap(() => {
this.state.$loadingPlanes.set(true);
return timer(3000).pipe(
finalize(() => this.state.$loadingPlanes.set(false)),
tap(() => this.state.$planes.set(getRandomItems()))
)
})
));
private generateShips = createEffect(_ => _.pipe(
exhaustMap(() => {
this.state.$loadingShips.set(true);
return timer(3000).pipe(
finalize(() => this.state.$loadingShips.set(false)),
tap(() => this.state.$ships.set(getRandomItems()))
)
})
));
}
To create a global store, add this decorator: @Injectable({ providedIn: ārootā })
Here, you can see that every time you click the big purple button āReload,ā both lists, āplanesā and āships,ā will be reloaded. The difference is that āplanesā will be loaded consecutively, as many times as you clicked the button. āShipsā will be loaded just once, and all consecutive clicks will be ignored until the previous request is completed.
Field $loading
is called āderivedā ā its value is created from the values of other signals, using computed()
. It is the most powerful part of Angular Signals. In comparison to derived selectors in observable-based stores, computed()
has some advantages:
- Dynamic dependency tracking: in the code above, when
$loadingPlanes()
returns true,$loadingShips()
will be removed from the list of dependencies. For non-trivial derived fields it might save memory; - Glitch-free without debouncing;
- Lazy computations: derived value will be recomputed not on every change of the signals it depends on, but only when this value is being read (or if the resulting signal is inside the
effect()
function or is used in the template).
And one disadvantage: you can not control dependencies, they all are tracked automatically.
Feature Store
@Injectable()
export class PlanesStore {
private readonly appStore = inject(AppStore);
private readonly state = {
$page: signal<number>(0),
$pageSize: signal<number>(10),
$displayDescriptions: signal<boolean>(false),
} as const;
public readonly $items = this.appStore.$planes;
public readonly $loading = this.appStore.$loadingPlanes;
public readonly $page = this.state.$page.asReadonly();
public readonly $pageSize = this.state.$pageSize.asReadonly();
public readonly $displayDescriptions = this.state.$displayDescriptions.asReadonly();
public readonly paginated = createEffect<PageEvent>(_ => _.pipe(
debounceTime(200),
tap((event) => {
this.state.$page.set(event.pageIndex);
this.state.$pageSize.set(event.pageSize);
})
));
setDisplayDescriptions(display: boolean) {
this.state.$displayDescriptions.set(display);
}
}
The root component (or a route) of the feature should āprovideā this store:
@Component({
// ...
providers: [
PlanesStore
]
})
export class PlanesComponent { ... }
Do not add this store to the providers of descendant components, otherwise, they will create their own, local instances of the feature store, and it will lead to unpleasant bugs.
Local Store
@Injectable()
export class ItemsListStore {
public readonly $allItems = signal<Item[]>([]);
public readonly $page = signal<number>(0);
public readonly $pageSize = signal<number>(10);
public readonly $items: Signal<Item[]> = computed(() => {
const pageSize = this.$pageSize();
const offset = this.$page() * pageSize;
return this.$allItems().slice(offset, offset + pageSize);
});
public readonly $total: Signal<number> = computed(() => this.$allItems().length);
public readonly $selectedItem = signal<Item | undefined>(undefined);
public readonly setSelected = createEffect<{
item: Item,
selected: boolean
}>(_ => _.pipe(
tap(({ item, selected }) => {
if (selected) {
this.$selectedItem.set(item);
} else {
if (this.$selectedItem() === item) {
this.$selectedItem.set(undefined);
}
}
})
));
}
Pretty similar to a feature store, the component should provide this store to itself:
@Component({
selector: 'items-list',
// ...
providers: [
ItemsListStore
]
})
export class ItemsListComponent { ... }
Component as a Store
What if our component is not so big and we are sure that it will remain not so big, and we just donāt want to create a store for this small component?
I have an example of a component, written this way:
@Component({
selector: 'list-progress',
// ...
})
export class ListProgressComponent {
protected readonly $total = signal<number>(0);
protected readonly $page = signal<number>(0);
protected readonly $pageSize = signal<number>(10);
protected readonly $progress: Signal<number> = computed(() => {
if (this.$pageSize() < 1 && this.$total() < 1) {
return 0;
}
return 100 * (this.$page() / (this.$total() / this.$pageSize()));
});
@Input({ required: true })
set total(total: number) {
this.$total.set(total);
}
@Input() set page(page: number) {
this.$page.set(page);
}
@Input() set pageSize(pageSize: number) {
this.$pageSize.set(pageSize);
}
@Input() disabled: boolean = false;
}
In some future versions of Angular, the input()
function will be introduced to create inputs as signals, making this code much shorter.
This example application is deployed here: GitHub Pages link.
You can play with it to see how the state of different lists is independent, how the feature state is shared across the components of a feature, and how all of them use the lists from the applicationās global state.
In the code, you can find examples of reactions to events, queueing of asynchronous state modifications, derived (computed) state fields, and other details.
I know we could improve the code and make things better ā but itās not the point of this example app. All the code here has only one purpose: to illustrate this article and to explain how things might work.
Iāve demonstrated how to manage an Angular application state without third-party libraries, using only Angular Signals and one additional function.
Thank you for reading!