Pagination with RxJS state, and Route Params
Tags: angular, typescript, rxjs, tutorial
Published: 2022-03-31 11:20:57

Expanding on [RxJS based state management](https://garage.sekrab.com/posts/rxjs-based-angular-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](https://stackblitz.com/edit/angular-state-local?file=src/app/components/products.component.ts)

### 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:
```typescript
@Injectable({ providedIn: 'root' })
export class ProductState extends ListStateService<IProduct> {}
```
In product list component, listen to the activated route, retrieve products, and list them.
```typescript
@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: 
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**:
```typescript
  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.
```html
<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`.
```typescript
  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:
```html
<div class="spaced">
   Show: <a (click)="showProducts(true)">Public</a>
 | <a (click)="showProducts(false)">Private</a>
</div>
```
And the function
```typescript
 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).
```typescript
 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`
```typescript
// 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 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](https://garage.sekrab.com/posts/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
```html
// 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)
```typescript
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:
```typescript
{
    path: '',
    component: PostListComponent
},
{
    // route param with same component
    path: ':slug',
    component: PostListComponent
}
```
The post list should now listens to a combination:
```typescript
 // 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. 
```typescript
this.router.navigate(['.', {category: 'eggs'}], { queryParams: { page: page+1 } });
```
And the `href` link:
```typescript
// 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](https://angular.io/api/router/ExtraOptions#scrollPositionRestoration), 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.