While refactoring my angular application I basically want to get rid of all subscriptions in order to use only async
pipe provided by angular (just a declarative approach instead of an imperative one).
I have problems to implement a declarative approach when multiple sources can lead to changes in the stream. If we only had one source then of course, I could just use scan
operator to build up my emitted values.
Scenario
Let's say I just want to have a simple component, where an array of strings is resolved during routing. In the component I want to display the list and want to be able to add or remove items using buttons.
Limitations
subscribe
, since I want angular to take care of unsubscription using (async
pipe)click
event propagation), since I don't think it is necessary. I should already have all needed observables, which just have to be "glued together".Current process so far My journey so far took several steps. Please note that all approaches worked fine, but each has their individual downsights):
BehaviorSubject
and .value
to create the new array --> not declarativescan
operator and create an Action
interface, where each button emits an action of type XY
. This action would be read inside the function passed to scan
and then use a switch to determine which action to take. This felt a little bit like Redux, but it was a strange feeling to mix different value types in one pipe (first initial array, afterwards actions).shareReplay
and use this instantly emitted value in my button, by switching to a new observable using concatMap
, where I only take 1 value in order to prevent creating a loop. Example implementation mentioned below:list-view.component.html:
<ul>
<li *ngFor="let item of items$ | async; let i = index">
{{ item }} <button (click)="remove$.next(i)">remove</button>
</li>
</ul>
<button (click)="add$.next('test2')">add</button>
list-view.component.ts
// simple subject for propagating clicks to add button, string passed is the new entry in the array
add$ = new Subject<string>();
// simple subject for propagating clicks to remove button, number passed represents the index to be removed
remove$ = new Subject<number>();
// actual list to display
items$: Observable<string[]>;
constructor(private readonly _route: ActivatedRoute) {
// define observable emitting resolver data (initial data on component load)
// merging initial data, data on add and data on remove together and subscribe in order to bring data to Subject
this.items$ = merge(
this._route.data.pipe(map((items) => items[ITEMS_KEY])),
// define observable for adding items to the array
this.add$.pipe(
concatMap((added) =>
this.items$.pipe(
map((list) => [...list, added]),
take(1)
)
)
),
// define observable for removing items to the array
this.remove$.pipe(
concatMap((index) =>
this.items$.pipe(
map((list) => [...list.slice(0, index), ...list.slice(index + 1)]),
take(1)
)
)
)
).pipe(shareReplay(1));
}
Nevertheless I feel like this should be the easiest example possible and my implementation seems to complex for this kind of issue. It would be great if someone could help in finding a solution to this, what should be a simple, problem.
You can find a StackBlitz example of my implementation here: https://stackblitz.com/edit/angular-ivy-yj1efm?file=src/app/list-view/list-view.component.ts
You can create a modifications$
stream that takes each emission from your "modification subjects", and maps them to a function that will modify the state accordingly:
export class AppComponent {
add$ = new Subject<string>();
remove$ = new Subject<number>();
private modifications$ = merge(
this.add$.pipe(map(item => state => state.concat(item))),
this.remove$.pipe(map(index => state => state.filter((_, i) => i !== index))),
);
private routeData$ = this.route.data.pipe(map(items => items[ITEMS_KEY]));
items$ = this.routeData$.pipe(
switchMap(items => this.modifications$.pipe(
scan((state, fn) => fn(state), items),
startWith(items)
))
);
constructor(private route: ActivatedRoute) { }
}
Here we define items$
to start with the route data, then switch to a stream that applies the incoming reducer functions to the state. We use the initial items
from route data as our seed value inside scan
. We also use startWith
to initially emit the initial items
.
Here's a little StackBlitz sample.