Reputation: 2796
I have a list of products coming from an API. This list is shown and paginated in a Component. A pagination change doesn't trigger a URL change or reload the component – but it does load a new set of products.
I need to get the list in the Component because I have to extract/modify some of its values. So it's not sufficient to simply use the AsyncPipe
in the template.
The solution I came up with is using a BehaviorSubject
. I wonder whether this approach is correct.
This is the Service:
export class ProductService {
public list$ = new BehaviorSubject<Product[]>(null);
getAll(criteria: any): Subscription {
const path = '/api';
return this.http.post<any>(path, criteria).pipe(
map((response: any) => {
// some mapping …
return response;
})
).subscribe(response => this.list$.next(response));
}
}
This is the Component:
export class ProductComponent implements OnInit, OnDestroy {
products: Product[];
page: number = 1;
constructor(
productService: ProductService
) { }
ngOnInit() {
this.productService.list$.subscribe(products => {
this.products = products;
});
this.loadProducts();
}
ngOnDestroy() {
this.productService.list$.unsubscribe();
}
loadProducts() {
this.productService.getAll({page: this.page});
}
onPageChange(page: number) {
this.page = page;
this.loadProducts();
}
}
My questions are:
Upvotes: 0
Views: 1071
Reputation: 13079
Is there a better way of doing this?
Yes, I believe there is.
Is this the right way?
No, I am afraid not.
Is everything subscribed and unsubscribed correctly?
Nope, each time you call ProductService.getAll
method, you create a new subscription without unsubscribing.
Does this fail, if I would load two lists with different criterias in the Controller? If yes, how can I fix this?
Yes it would fail, because service is pushing values to one single subject. You can fix this by making service stateless.
A better way imho:
export class ProductService {
// query would be a better name, because it doesn't literally get all.
query(criteria: any): Observable<Product[]> {
const path = '/api';
// This way service stays stateless.
return this.http.post<Product[]>(path, criteria).pipe(
map((response: any) => {
// some mapping …
return response;
})
);
}
}
Then in the component, you can keep the page number in a subject and pipe for changes. Using async
pipe together with some mapping could still be an option, but not a must have.
export class ProductComponent implements OnInit, OnDestroy {
products: Product[];
page$ = new BehaviourSubject<number>(1);
products$: Observable<Product[]>;
destroyed$ = new Subject<void>();
constructor(
productService: ProductService
) { }
ngOnInit() {
this.products$ = page$
.pipe(
switchMap((page: number) => this.productService.query({page}))
);
this.products$
.pipe(takeUntil(this.destroyed$))
.subscribe((products: Product[]) => this.products = products);
}
ngOnDestroy() {
this.destroyed$.next();
}
onPageChange(page: number) {
this.page$.next(page);
}
}
As a bonus, you can extract a base class with destroyed$
subject, so you can share it among components:
export class BaseComponent implements OnInit {
readonly destroyed$ = new Subject<void>();
ngOnDestroy() {
this.destroyed$.next();
}
}
Then your component would be:
export class ProductComponent extends BaseComponent implements OnInit {
products: Product[];
page$ = new BehaviourSubject<number>(1);
products$: Observable<Product[]>;
constructor(productService: ProductService) {
super();
}
ngOnInit() {
this.products$ = page$
.pipe(
switchMap((page: number) => this.productService.query({page}))
);
this.products$
.pipe(takeUntil(this.destroyed$))
.subscribe((products: Product[]) => this.products = products);
}
onPageChange(page: number) {
this.page$.next(page);
}
}
Upvotes: 1
Reputation: 11944
Is there a better way of doing this?
I wouldn't say this is a better way, it's just personal preference. With this approach you can avoid the explicit subscription in your component:
class ProductService {
private listSrc = new Subject();
private path = '/api';
// Available for data consumers
list$ = this.listSrc.pipe(
mergeMap(
criteria => this.http.post(this.path, criteria)
.pipe(
map(/* ... */),
// You can also handle errors here
// catchError()
)
),
// Might want to add a multicast operator in case `list$` is used in multiple places in the template
);
getAll (criteria) {
this.listSrc.next(criteria);
}
}
class ProductComponent {
get list$ () {
return this.productService.list$;
}
constructor (/* ... */) { }
ngOnInit() {
this.loadProducts();
}
loadProducts () {
this.productService.getAll({page: this.page});
}
}
And now you can consume list$
with the async pipe.
Does this fail, if I would load two lists with different criterias in the Controller? If yes, how can I fix this?
In that case, if that list is still related to products, I'd just add another property in the productService
, that follows the same pattern:
anotherProp$ = this.anotherPropSrc.pipe(
/* ... */
)
Upvotes: 1
Reputation: 96899
I think what you're doing is correct. If you use async
pipe to subscribe to list$
it will unsubscribe automatically. You probably don't need to unsubscribe from getAll()
because it makes a single HTTP request but if you want to make super sure nothing is left pending after you destroy the component you should keep the subscription in a property and unsubscribe in ngOnDestroy()
.
You could maybe put everything into a single chain to avoid that:
private refresh$ = new Subject();
public list$ = this.refresh$
.pipe(
switchMap(criteria => this.http.post<any>('/api', criteria)),
share(),
);
...
getAll(criteria: any) {
this.refresh$.next(criteria);
}
Upvotes: 1