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 you 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, but 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 quite 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 original effect() 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()
.
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.generateA();
this.generateB();
}
private generateA = 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 generateB = 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.
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 version 17 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.
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!
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.
Top comments (0)