Collections State Management Service for Angular
In this article, I’ll show you how to use the ngx-collection library and why you might want to do this.
Last update: July 9, 2023
Purpose
Every web app has some list or uses some list of entities/items internally. Because of that, there are multiple libraries to help with that, for different web frameworks. In comparison to ngx-collection, they have differences and similarities — choose what you find more suitable and comfortable to use :)
Collection Service will help you manipulate collections: create a collection of items, and safely update one or multiple items (without mutating them).
In addition, you’ll get indicators that you can use to monitor collection statuses:
- isReading (some request is running currently to replace all the items in the collection)
- isCreating (request is running to add a new item to the collection)
- isUpdating
- isRefreshing
- isDeleting
- isMutating (some items are currently being updated or removed)
- isSaving (some items are currently being updated or created)
- isProcessing (there is at least one running request)
Using them, you can easily modify your view to reflect all the processes related to the collection — spinners, ‘disabled’ status of buttons, ‘expanded’/’collapsed’ statuses of the cards, ‘focused’/’selected’ statuses of the items.
Benefits
✅ No data race in asynchronous calls:
- works in reactive apps with the OnPush change detection strategy;
- works in components with the Default change detection strategy;
- can be used with just the
async
pipe andsubscribe()
, or with some store; - built-in support for Angular Signals 🚦!
✅ The service is not opinionated:
- it will not dictate how you should communicate with your APIs or other data sources;
- you decide how to run and cancel your requests. Every mutation method returns an observable, so you can decide how to orchestrate them using methods like
switchMap()
,mergeMap()
,concatMap()
,forkJoin()
, or something else; - Data sources can be synchronous (you can use
of()
orsignal()
for this purpose).
✅ Safety guarantees:
- 100% immutability;
- Duplicates prevention;
- Errors and exceptions will be handled correctly;
- Strictly typed — advanced type-checking and code completion.
Collection
Let’s say you are an art collector, and you need an app to rate your favorite paintings.
We can create a new collection just like that:
const coll = new Collection<Painting>({
comparatorFields: ['url']
});
Here you can notice the most important thing for Collection Service: it needs to know the field, that can be used to compare objects and declare them equal.
Because on every mutation we are re-creating the list (to have an immutable collection), the usual ===
(comparison of objects by reference) is not enough.
The built-in comparator will let you use one or multiple fields, composite fields, or nested fields. Or, you can define your function or class and use it as a comparator.
Data Source
Collection Service has just one responsibility: manipulate a collection without modifying the items themselves.
To provide the items, you’ll need some service — it can be a tiny service using Angular HttpClient, or some sophisticated API client with caching, invalidation, cross-updates, and other fancy things — Collection Service will use the observables or signals they provide. There are no built-in tools in Collection Service to communicate with the data providers — it gives you absolute freedom of implementation and full flexibility for combining different tools.
In our example app, we’ll use a simple service that will mock API client behavior:
export class PaintingsService {
getPaintings(): Observable<Painting[]> {
return timer(1000).pipe(
map(() =>
[
{
url: '...',
title: 'A Walk at Twilight',
artist: 'Vincent van Gogh',
date: '1889-1890',
},
{
url: '...',
title: 'Siesta',
artist: 'Joaquín Sorolla',
date: '1911',
},
// ...
] // shuffle
.map((value) => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
)
);
}
ratePainting(painting: Painting, rate: number): Observable<Painting> {
return timer(2000).pipe(
map(() => ({
...painting,
rate,
}))
);
}
}
where Painting
model has this structure:
export type Painting = {
readonly url: string;
readonly title: string;
readonly artist: string;
readonly date: string;
readonly rate?: number;
};
All the fields are readonly
to be sure that we will not accidentally mutate our items.
Shared Collections
One of the design principles of this library: it should be easy to create simple apps and components using Collection Service, but it also should be usable in the apps and components of any level of complexity.
Using ngx-collection, you can create shared collections — if one of your components will update an item, every other component will instantly reflect it on the page. For that, you’ll need to just modify the “Injectable” decorator params:
@Injectable({ providedIn: 'root' })
export class PaintingsCollection extends Collection<Painting> {
constructor() {
super({ comparatorFields: ['url'] });
}
}
If you remove { providedIn: ‘root’ }
, you still can inject it into the components or services, but the collection instance will be shared only between the components, whose parent component has PaintingsCollection
in their providers
. If a component will add PaintingsCollection
to its own providers
, a new instance of PaintingsCollection will be created — that’s how Angular DI works.
Component
And now the place where we are going to use our collection: the component, responsible for rendering and updating the items.
In this implementation, I’m using the NgRx Component Store as the place where all the component’s logic will be located. It is not required — you can use just regular subscriptions instead, but it is something I recommend doing.
export interface PaintingsListState {}
@Injectable()
export class PaintingsListStore extends ComponentStore<PaintingsListState> {
readonly collection = inject(PaintingsCollection);
constructor(private readonly api: PaintingsService) {
super({});
this.load();
}
private readonly load = this.effect((_) => _.pipe(
switchMap(() => this.collection.read({
request: this.api.getPaintings(),
})
)
)
);
readonly rate = this.effect<{ painting: Painting; rate: number }>((_) =>
_.pipe(
switchMap(({ painting, rate }) =>
this.collection.update({
request: this.api.ratePainting(painting, rate),
item: painting,
})
)
)
);
readonly remove = this.effect<Painting>((_) =>_.pipe(
mergeMap((painting) =>
this.collection.delete({
request: timer(1000),
item: painting,
})
)
)
);
}
Let’s take a look at our event handlers.
First is load
:
load = this.effect((_) => _.pipe(
switchMap(() => this.collection.read({
request: this.api.getPaintings(),
})
)));
When load
event will occur, we’ll call collection.read()
and will useapi.getPaintings()
request.
Then goes rate
:
rate = this.effect<{ painting: Painting; rate: number }>((_) =>_.pipe(
switchMap(({ painting, rate }) =>
this.collection.update({
request: this.api.ratePainting(painting, rate),
item: painting,
})
)));
Here we are using input params, painting
and rate
. When an event occurs, we are calling collection.update()
, request is api.ratePainting()
and here we need another parameter: item
.
It should be the item that we want to update (or just any object with the same primary key).
By using switchMap
, we’ll cancel the currently running rate
request (if any are running right now), so only the last rate
will be applied. Collection Service will correctly handle it because every action is asynchronous.
The last one here is remove
:
remove = this.effect<Painting>((_) =>_.pipe(
mergeMap((painting) =>
this.collection.delete({
request: timer(1000),
item: painting,
})
)));
Just two differences from rate
:
1. We are using mergeMap
because we allow removing items in parallel.
2. We are using timer
as request
because our API client has no method to remove items (it’s just a mock after all).
Our component itself will be tiny:
@Component({
selector: 'paintings-list',
templateUrl: './paintings-list.component.html',
styleUrls: ['./paintings-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
// ...
],
providers: [PaintingsListStore],
})
export class PaintingsListComponent {
protected readonly store = inject(PaintingsListStore);
protected readonly coll = this.store.collection;
}
that’s it!
Just two fields: the store, and the View Model.
In the template, we’ll render a list, a spinner, a painting, rating stars, and a “delete” button.
<mat-card class="spinner" *ngIf="coll.$isReading()">
<mat-spinner
mode="indeterminate"
[diameter]="50"
color="accent"
></mat-spinner>
</mat-card>
<mat-accordion>
@for(item of coll.$items(); track item.url) {
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>{{ item.title }}</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-card>
<mat-card-header>
<mat-card-title>{{ item.artist }}</mat-card-title>
<mat-card-subtitle>{{ item.date }}</mat-card-subtitle>
</mat-card-header>
<img
mat-card-image
[src]="item.url"
class="frame"
[ngClass]="{ mutating: coll.$isMutating() }"
/>
</mat-card>
</ng-template>
<mat-action-row class="actions">
<div class="stars" [ngClass]="{ spin: coll.$isUpdating() }">
<mat-icon (click)="store.rate({ painting: item, rate: 1 })">{{
item.rate ? 'star' : 'star_border'
}}</mat-icon>
<mat-icon (click)="store.rate({ painting: item, rate: 2 })">{{
item.rate && item.rate > 1 ? 'star' : 'star_border'
}}</mat-icon>
<mat-icon (click)="store.rate({ painting: item, rate: 3 })">{{
item.rate && item.rate > 2 ? 'star' : 'star_border'
}}</mat-icon>
<mat-icon (click)="store.rate({ painting: item, rate: 4 })">{{
item.rate && item.rate > 3 ? 'star' : 'star_border'
}}</mat-icon>
<mat-icon (click)="store.rate({ painting: item, rate: 5 })">{{
item.rate && item.rate > 4 ? 'star' : 'star_border'
}}</mat-icon>
</div>
<button
mat-mini-fab
color="warn"
[disabled]="coll.$isDeleting()"
(click)="store.remove(item)"
>
<mat-icon>delete</mat-icon>
</button>
</mat-action-row>
</mat-expansion-panel>
}
</mat-accordion>
Add some styling and our app is ready: StackBlitz 🖼️
You can notice visual effects, highlighting the actions.
The “Delete” button can be called once, so it is rendered “disabled” while the item is being removed.
Stars can be clicked without limitations, so they are not disabled — only the last rating will be saved, and previous requests will be canceled.
Documentation & API
Collection Service has multiple methods, but to start using it, you need to know just 4 of them:
- create
- read
- update
- delete
That’s it!
Other methods are here just as additional utilities and helpers.
Full API documentation can be found in the repository (GitHub) — it should answer all of your questions about the details, not explained in this article.
Tools
As additional bonuses (that you are not required to use or learn), you can find pipes and helpers, such as getTrackByFieldFn
or |itemStatus
pipe.
I’ll be happy if this library is useful for you!