Pagination with RxJS state, and Route Params
Ayyash
Posted on March 31, 2022
Expanding on RxJS based state management, I will attempt to paginate a list through route parameter, and try to fix as many issues as one article can handle.
The solution is on StackBlitz
Listening to the right event
In the previous articles, the page
param was fed by code through its own paramState
. Today we will watch route.ParamMap
, to retrieve the page, amongst other params, if we want our public pages to be crawlable properly.
Setting up the stage with product service and model, as previously. And creating a product state:
@Injectable({ providedIn: 'root' })
export class ProductState extends ListStateService<IProduct> {}
In product list component, listen to the activated route, retrieve products, and list them.
@Component({
template: `
<div *ngIf="products$ | async as products">
<ul class="rowlist" *ngFor="let product of products.matches">
<li>{{ product.name }} - {{product.price }}</li>
</ul>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ParamState] // we are going to use this later
})
export class ProductListComponent implements OnInit {
products$: Observable<IList<IProduct>>;
constructor(
private productService: ProductService,
private productState: ProductState,
private route: ActivatedRoute
) { }
ngOnInit(): void {
// listen to route changes
this.products$ = this.route.paramMap.pipe(
switchMap((params) =>
// TODO: update state list, and decide when to append, and when to empty
this.productService.GetProducts({
page: +params.get('page') || 1,
size: 5,
})
)
);
}
}
The final result should meet the following:
- when page param changes, append to current list
- when other params that affect list change, empty list and reset page
- when page is less, do nothing
First, let me add the paramState
to keep track of current page, and let me throw in another param: isPublic
. It initially looks like this, notice the nested switchMap:
this.products$ = this.route.paramMap.pipe(
map((p) => {
return {
page: +p.get('page') || 1,
isPublic: p.get('public') === 'true', // false or true only
size: 2,
};
}),
switchMap((params) =>
this.productService.GetProducts(params).pipe(
// nested pipe to have access to "params"
switchMap((products) => {
// calculate if has more
const _hasMore = hasMore(products.total, params.size, params.page);
// update state, the only place to update state
this.paramState.UpdateState({
total: products.total,
hasMore: _hasMore,
...params,
});
// append to product state
return this.productState.appendList(products.matches);
})
)
)
);
The html template looks a bit messy, with two observables, we must keep an eye on which comes first.
<div *ngIf="products$ | async as products">
<ng-container *ngIf="params$ | async as params">
<p>Total: {{ params.total }}</p>
<ul class="rowlist" >
<li *ngFor="let item of products">
{{ item.name }} - {{item.price }}
</li>
</ul>
Page {{params.page}}
<a class="btn" (click)="nextPage()" *ngIf="params.hasMore">Next</a>
</ng-container>
</div>
A side note
I tried many ways to make the paramState update
part of the observable chain, it all went south. And it makes sense, updating a state in a chain of pipes is not safe.
Navigation
Clicking next, will navigate with a new page parameter, which then will be caught by our route listener above. The only thing we need to pay attention to is passing the matrix params that affect the result. In this case, isPublic
and page
.
nextPage() {
// increase page, and get all other params
const page = this.paramState.currentItem.page + 1;
const isPublic = this.paramState.currentItem.isPublic;
// dependency of Angular router
// this produces a url of /products;page=2;public=false
this.router.navigate(['.', { page, public: isPublic }]);
}
Extra parameters
Let's add a couple of links to change isPublic
from template:
<div class="spaced">
Show: <a (click)="showProducts(true)">Public</a>
| <a (click)="showProducts(false)">Private</a>
</div>
And the function
showProducts(isPublic: boolean) {
// simple routing event, what will happen to page?
this.router.navigate(['.', { public: isPublic, page: 1 }]);
}
If page is 1, clicking those links will do nothing. If page is 2, it will reset to page one, but will append to list. So our second condition is:
- when other params that affect list change, empty list and reset page
To fix that, we need an operator smarter than distinctUntilKeyChanged
. We need distinctUntilChanged
. We are also making use of this chained pipe, to empty the list if the param changes (two in one, yippee).
distinctUntilChanged((prev, next) => {
// if certain params change, empty list first
if (prev.isPublic !== next.isPublic) {
this.productState.emptyList();
}
// if neither changes return true
return prev.page === next.page && prev.isPublic === next.isPublic;
}),
Navigating back
If we paginate to higher pages, then click back on the browser, the previous records will append to the current list. Our third rule was:
- When page is less, do nothing
Using the same disctinctUntilChanged
we can filter out any reducing changes to page
// change the rule to exclude changes of previous page being larger
return prev.page >= next.page && prev.isPublic === next.isPublic;
This one is cool, the prev.page
is stuck at one value until the condition is false, so browsing forward has the pleasant result of not appending. The next.page
is progressed silently.
Navigation side effects
The major issue with this setup is moving backward and forward, between different pages and with different links. This problem cannot be fully fixed, we compromise:
- Using
replaceUrl
One of thenavigationExtras
is to replace the url in history, thus clicking next does not build a history recrod, hitting the back button goes to the previous page (away from the current component).
this.router.navigate(['.', { page, public: isPublic }], { replaceUrl: true });
If user is already on a page that has page=2
in the URL, and refreshes, it will display the second page. But it will act correctly afterwards.
If however we click on projects link in the navigation, that will add to history, and kind of disrupt the sequence with the back and forward.
To reproduce the effect you really must act like a monkey pressing buttons and navigation link without leaving page. But the web is full of monkies. 🐒🐒
- Using
skipLocationChange
This replaces history record without changing the displayed url. The url will always be what you initially provide for the user.
this.router.navigate(['.', { page, public: isPublic }], { skipLocationChange: true });
In additon to the side effects of replaceUrl
, if user comes into this page with a param in the URL, the URL will not adjust itself on subsequent links, creating confusion.
I would choose replaceUrl
, as it is more natural. But if I had a deeper link with higher chance of backward navigation, I would choose a combination of both.
SEO considerations
In my post SEO in Angular with SSR - Part II, I referred to the Href versus Click for google bot. The click to navigate does not cut it for SEO, because the bot does not run a click event, it only runs the initial scripts to load content, then looks for href
attributes. To make it ready for SEO, we need to set a proper href
.
NavigationExtras
are important to us, thus, we cannot setrouterLink
directly. That would have been the first choice.
Back to our component, pass the $event
attribute with clicks, and setup the stage for href
attributes
// change links
Show:
<a [href]="getShowLink(true)" (click)="showProducts(true, $event)">Public</a> |
<a [href]="getShowLink(false)" (click)="showProducts(false, $event)">Private</a>
Next:
<a class="btn" [href]="getNextLink()" (click)="nextPage($event)" *ngIf="params.hasMore">Next</a>
Then cancel the click event (for browser platform), and return a proper url for href
(for SEO crawler)
nextPage(event: MouseEvent) {
// prevent default click
event.preventDefault();
// ... etc
}
showProducts(isPublic: boolean, event: MouseEvent) {
event.preventDefault();
// ... etc
}
getNextLink() {
const page = this.paramState.currentItem.page + 1;
const isPublic = this.paramState.currentItem.isPublic;
// construct a proper link
return `/products;page=${page};public=${isPublic}`;
}
getShowLink(isPublic: boolean) {
return `/products;page=1;public=${isPublic}`;
}
Params vs QueryParams.
Google Search Guidelines does not speak against matrix parameters, neither speaks of them. Google Analytics however strips them out. If we do not set any canonical links
for our pages, matrix parameters work well for SEO. There is one scenario though, that makes it compuslory to switch to query params. And that is, if you have the paginated list on root of your site.
Matrix params are not supported on root
Yes you heard that right. And this is not "rare". Your blog homepage is an example of a paginated list, on the root. We can combine all params in one shot, and to aim for extreme, lets say we have a root url: www.domain.com?page=1
. And a category page www.domain.com/eggs/?page=1
. Where the route in Angular looks like this:
{
path: '',
component: PostListComponent
},
{
// route param with same component
path: ':slug',
component: PostListComponent
}
The post list should now listens to a combination:
// example of combining queryParams with route params
this.postlist$ = combineLatest([this.route.queryParamMap, this.route.paramMap]).pipe(
map((p) => {
return {
page: +p[0].get('page') || 1,
category: p[1].get('category'),
size: 2
};
}), // ... the rest
The navigation would now look like this.
this.router.navigate(['.', {category: 'eggs'}], { queryParams: { page: page+1 } });
And the href
link:
// reconstruct, you can add matrix params first, then append query params
return `/products/${category}/?page=${page+1}`;
Scrolling
This is going to be the real heart breaker. To get the rigth behavior, in root RouterModule
it is better to set scrollPositionRestoration: 'enabled',
. As documented in Angular, clicking on the next
link, will scroll to top. Outch. To solve this... stay tuned till next week. I promised myself I won't digress, and I shall not. 😴
Thanks for reading this far, let me know if you spot any elephants.
Posted on March 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.