Pagination with RxJS state, and Route Params

ayyash

Ayyash

Posted on March 31, 2022

Pagination with RxJS state, and Route Params

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> {}
Enter fullscreen mode Exit fullscreen mode

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,
        })
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The final result should meet the following:

  1. when page param changes, append to current list
  2. when other params that affect list change, empty list and reset page
  3. 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);
          })
        )
      )
    );
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }]);
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

And the function

 showProducts(isPublic: boolean) {
    // simple routing event, what will happen to page?
    this.router.navigate(['.', { public: isPublic, page: 1 }]);
  }
Enter fullscreen mode Exit fullscreen mode

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;
      }),
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 the navigationExtras 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 set routerLink 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>
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The navigation would now look like this.

this.router.navigate(['.', {category: 'eggs'}], { queryParams: { page: page+1 } });
Enter fullscreen mode Exit fullscreen mode

And the href link:

// reconstruct, you can add matrix params first, then append query params
return `/products/${category}/?page=${page+1}`;
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
ayyash
Ayyash

Posted on March 31, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related