Application State Management with Angular Signals

šŸŖ„ OZ šŸŽ©
8 min readJul 25, 2023

--

ā€œThe Sea at Les Saintes-Maries-de-la-Merā€, Vincent van Gogh, 1888

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:

  1. createEffect() is a standalone function. Under the hood, it subscribes to an observable, and because of that createEffect() can only be called in an injection context. Thatā€™s exactly how we were using the original effect() method;
  2. createEffect() function will resubscribe on errors, which means that it will not break if you forget to add catchError() 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:

  1. Global Store (application level) ā€” accessible to every component and service in your application;
  2. Feature Store (ā€œfeatureā€ level) ā€” accessible to the descendants of some particular feature;
  3. 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!

--

--