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
- I don't want to use
subscribe
, since I want angular to take care of unsubscription using (async
pipe) - I don't want to use BehaviorSubject.value, since it's (from my point of view) an imperative approach instead of a declarative one
- Actually I don't want to use any kind of subject at all (apart from the ones used for button
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):
- Usage of
BehaviorSubject
and.value
to create the new array --> not declarative - Trying
scan
operator and create anAction
interface, where each button emits an action of typeXY
. This action would be read inside the function passed toscan
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). - My so far favorite approach is the following: I basically mimic a BehaviorSubject by using
shareReplay
and use this instantly emitted value in my button, by switching to a new observable usingconcatMap
, 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
CodePudding user response:
To start, you don't need subjects to propagate HTML events.
Under the hood, Angular uses an EventEmitter
, which is basically a subject, to propagate changes throughout the app.
So this
<button (click)="remove$.next(i)">remove</button>
Should become this
<button (click)="removeItem(item, i)">remove</button>
Next, for the route data, you can use simple operators to create a subject for them
routeData$ = this._route.data.pipe(pluck('ITEMS_KEY'), shareReplay(1)),
Now, this gives you a cleaner code for your component :
routeData$ = this._route.data.pipe(pluck('ITEMS_KEY'), shareReplay(1)),
constructor(private readonly _route: ActivatedRoute) {}
addItem(item: any) {
// ...
}
removeItem(item: any) {
// ...
}
Finally, you have to decide how this affects your data. What do addItem
and removeItem
should do in fine ? You have several options there, for instance :
- Make http calls to your API
- Update your application state
- Redirect to the same route, but update the data / route params, etc
With this, you should be able to get rid of all subscriptions and let Angular do the work for you.
Even better, you can now switch to the OnPush
detection strategy and greatly increase your application performance !
CodePudding user response:
This is my take on it: https://stackblitz.com/edit/angular-ivy-nsxabg?file=src/app/list-view/list-view.component.ts
I think having Subjects for simulating events is an overkill and if you just create new state for the items$ observable in two functions you have a neat solution.
CodePudding user response:
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.