DEV Community

Cover image for Handling pagination with NgRx component stores
Pierre Bouillon for This is Angular

Posted on • Updated on

Handling pagination with NgRx component stores

NgRx is a popular state management library for Angular and is widely used to implement the Redux pattern in your applications.

However, depending on the use cases, you might not always want to use the whole artillery that a state is made of.

For such scenarios, NgRx introduced a lighter, local way of managing the store by component: the component stores.

In this article we will see a quick example of how to setup a simple component store taking advantage of the latest NgRx API and how to use it to paginate a list of items.

This guide is designed as a step by step lab during which we will gradually implement the pagination with component stores. If you would like to jump straight to the final implementation, click here


Table of content


Initial Setup

Our application will simply display a list of paginated todo items.

We won't implement the logic to toggle, edit, add or perform any other action on them since this is not the main focus of this article.

Creating the Angular application

For our Angular app, I will use Angular v15.2 which is the latest at the time I am writing this article.

In a new directory, type in the following command:

ng new --directory ./  --minimal --inline-template --skip-tests
Enter fullscreen mode Exit fullscreen mode

You can then leave all the other options to their default value and head of to your favorite code editor and get rid of the initial content of the app.component.ts and replace it by the following:

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h1>Simple pagination with NgRx component stores</h1>
  `,
  styles: []
})
export class AppComponent {
}
Enter fullscreen mode Exit fullscreen mode

Since we will be using standalone components, you can also remove the app.module.ts and replace the application startup in the main.ts with this:

// main.ts
import { provideHttpClient } from "@angular/common/http";
import { bootstrapApplication } from "@angular/platform-browser";

import { AppComponent } from "./app/app.component";

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient()],
}).catch((err) => console.error(err));

Enter fullscreen mode Exit fullscreen mode

We can now hit ng serve and proceed with our demo!

Faking our backend

To retrieve todo items, I will use the excellent JSON placeholder API to mimick a backend.

For that, let's first create a contract in a newly created file todo-item.ts:

// todo-item.ts
export interface TodoItem {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}
Enter fullscreen mode Exit fullscreen mode

We can now proceed an implement our TodoItemService to retrieve them:

// todo-item.service.ts
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";

import { Observable } from "rxjs";

import { TodoItem } from "./todo-item";

@Injectable({ providedIn: "root" })
export class TodoItemService {
  private readonly _http = inject(HttpClient);

  getTodoItems(offset?: number, pageSize?: number): Observable<TodoItem[]> {
    const params = new HttpParams({
      fromObject: {
        _start: offset ?? 0,
        _limit: pageSize ?? 10,
      },
    });

    return this._http.get<TodoItem[]>(
      "https://jsonplaceholder.typicode.com/todos",
      { params }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating our component

With our service created, we can consume the contract from a new TodoItemComponent:

// todo-item.component.ts
import { NgIf } from "@angular/common";
import { Component, Input } from "@angular/core";

import { TodoItem } from "./todo-item";

@Component({
  selector: "app-todo-item",
  standalone: true,
  imports: [NgIf],
  template: `
    <div *ngIf="todoItem">
      <input type="checkbox" [checked]="todoItem.completed" />
      <span>#{{ todoItem.id }} - {{ todoItem.title }}</span>
    </div>`,
})
export class TodoItemComponent {
  @Input() todoItem?: TodoItem;
}
Enter fullscreen mode Exit fullscreen mode

This is arguably not the most good looking todo item display, I give you that

And finally use our bricks to display our first page:

// app.component.ts
import { AsyncPipe, NgFor } from "@angular/common";
import { Component, inject } from "@angular/core";

import { TodoItemComponent } from "./todo-item.component";
import { TodoItemService } from "./todo-item.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [NgFor, AsyncPipe, TodoItemComponent],
  template: `
    <h1>Simple pagination with NgRx component stores</h1>

    <app-todo-item
      *ngFor="let todoItem of todoItems$ | async"
      [todoItem]="todoItem"
    />
  `,
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);
  readonly todoItems$ = this._todoItemService.getTodoItems();
}
Enter fullscreen mode Exit fullscreen mode

You should new see our homepage with some todo items:

homepage v1

Handling the pagination

With our app up and running with some todo items displayed, we can now focus on our pagination issue.

We will just slightly update our AppComponent to give our user access to the previous and next page:

// app.component.ts
// ...

@Component({
  // ...
  template: `
    <h1>Simple pagination with NgRx component stores</h1>

    <app-todo-item
      *ngFor="let todoItem of todoItems$ | async"
      [todoItem]="todoItem"
    />

    <!-- πŸ‘‡ Pagination buttons -->
    <button type="button" aria-label="Previous Page" (click)="onPreviousPage()">
      ←
    </button>

    <button type="button" aria-label="Next Page" (click)="onNextPage()">
      β†’
    </button>

  `,
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);
  readonly todoItems$ = this._todoItemService.getTodoItems();

  // πŸ‘‡ Associated handlers
  onPreviousPage(): void {}
  onNextPage(): void {}
}
Enter fullscreen mode Exit fullscreen mode

The simplest way

The most simple way could be to introduce a variable holding the pagination details and, on click, update its value an reassign the todoItems$ observable:

// app.component.ts
// ...

@Component({
  // ...
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);
  todoItems$ = this._todoItemService.getTodoItems();

  private _pagination = {
    offset: 0,
    pageSize: 10,
  };

  onPreviousPage(): void {
    this._pagination.offset -= this._pagination.pageSize;

    const { offset, pageSize } = this._pagination;
    this.todoItems$ = this._todoItemService.getTodoItems(offset, pageSize);
  }

  onNextPage(): void {
    this._pagination.offset += this._pagination.pageSize;

    const { offset, pageSize } = this._pagination;
    this.todoItems$ = this._todoItemService.getTodoItems(offset, pageSize);
  }
}
Enter fullscreen mode Exit fullscreen mode

However this approach is not very reactive and we must implement the logic imperatively all over the place (which is just two methods in our case).

Surely we can do better.

With subjects

Using RxJS BehaviorSubject we can hold a single state of our pagination details an dynamically react to any change of value to update our queried todo items:

// app.component.ts
// ...

@Component({
  // ...
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);

  // πŸ‘‡ Introducing a subject holding the pagination details
  private readonly _pagination$ = new BehaviorSubject({
    offset: 0,
    pageSize: 10,
  });

  // πŸ‘‡ Reactively update the todo items on pagination change
  readonly todoItems$ = this._pagination$.pipe(
    switchMap(({ offset, pageSize }) =>
      this._todoItemService.getTodoItems(offset, pageSize)
    )
  );

  onPreviousPage(): void {
    const { offset, pageSize } = this._pagination$.getValue();

    this._pagination$.next({
      offset: offset - pageSize,
      pageSize,
    })
  }

  onNextPage(): void {
    const { offset, pageSize } = this._pagination$.getValue();

    this._pagination$.next({
      offset: offset + pageSize,
      pageSize,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

This may be a better but we still have our pagination logic within our component, along with the state of it.

This looks like a great use case for a component store!

Adding state management

Before using the component store, we first should install the associated package:

npm install @ngrx/component-store --save
Enter fullscreen mode Exit fullscreen mode

Once the installation is complete, create a new file named app.component-store.ts inside which we will first define our state.

Our state should contain:

  • The TodoItems to display
  • The pagination details (the offset and the page size)
export interface AppState {
  todoItems: TodoItem[];
  offset: number;
  pageSize: number;
}
Enter fullscreen mode Exit fullscreen mode

For our store to be used, we also need an initial state used to initialize the store:

const initialState: AppState = {
  todoItems: [],
  offset: 0,
  pageSize: 10,
};
Enter fullscreen mode Exit fullscreen mode

We can now create our store and initialize it with our initial state:

@Injectable()
export class AppComponentStore extends ComponentStore<AppState> {
  constructor() {
    super(initialState);
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, if we want to use it, we should provide it to our AppComponent using the provideComponentStore method:

// app.component.ts
@Component({
  // ...
  providers: [
    // πŸ‘‡ Provide our component store to our component
    provideComponentStore(AppComponentStore),
  ]
})
export class AppComponent {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

A common way of using component stores is by exposing a view model as vm$ for the component to consume.

Let's take advantage of this syntax to remove the former logic from our template:

// app.component-store.ts
@Injectable()
export class AppComponentStore extends ComponentStore<AppState> {
  readonly vm$ = this.select(({ todoItems }) => ({ todoItems }));

  constructor() {
    super(initialState);
  }
}
Enter fullscreen mode Exit fullscreen mode
// app.component.ts
@Component({
  selector: "app-root",
  standalone: true,
  imports: [NgIf, NgFor, AsyncPipe, TodoItemComponent],
  template: `
    <!-- πŸ‘‡ Wrap the view model in a container with an async pipe to rerender on new values -->
    <ng-container *ngIf="vm$ | async as vm">
      <h1>Simple pagination with NgRx component stores</h1>

      <!-- πŸ‘‡ Use the todo items exposed by the view model -->
      <app-todo-item
        *ngFor="let todoItem of vm.todoItems"
        [todoItem]="todoItem"
      />

      <button type="button" aria-label="Previous Page" (click)="onPreviousPage()">
        ←
      </button>

      <button type="button" aria-label="Next Page" (click)="onNextPage()">
        β†’
      </button>
    </ng-container>
  `,
  providers: [provideComponentStore(AppComponentStore)],
})
export class AppComponent {
  // πŸ‘‡ Consume the component store and its API instead of
  // handling the logic here
  private readonly _componentStore = inject(AppComponentStore);
  readonly vm$ = this._componentStore.vm$;

  onPreviousPage(): void {}

  onNextPage(): void {}
}
Enter fullscreen mode Exit fullscreen mode

We're good to go!

With our component store initialized, we can now move our pagination logic into it.

In this kind of store, as for the more "classical" ones, we can divide our code into three main sections:

  • Selectors
  • Effects
  • Reducer

In a component store, selectors are frequently let aside in favor or the view model.

However, we could fire some effects and have them impact the store.

Using this approach, let's raise some events regarding the page offset and update our state accordingly

@Injectable()
export class AppComponentStore extends ComponentStore<AppState> {
  private readonly _todoItemService = inject(TodoItemService);

  readonly vm$ = this.select(({ todoItems }) => ({ todoItems }));

  constructor() {
    super(initialState);
  }

  // πŸ‘‡ Effect loading the todo items
  readonly loadNextPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.select((state) => state)),
      map(([, state]) => state),
      tap(({ offset }) => this.updateOffset(offset + 1)),
      switchMap(({ offset, pageSize }) =>
        this._todoItemService.getTodoItems(offset * pageSize, pageSize).pipe(
          tapResponse(
            (todoItems: TodoItem[]) => this.updateTodoItems(todoItems),
            () => console.error("Something went wrong")
          )
        )
      )
    );
  });

  // πŸ‘‡ Updaters for our state
  private readonly updateOffset = this.updater(
    (state: AppState, offset: number) => ({
      ...state,
      offset,
    })
  );

  private readonly updateTodoItems = this.updater(
    (state: AppState, todoItems: TodoItem[]) => ({
      ...state,
      todoItems,
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

The effect for the previous page is pretty much the same, try to implement it yourself!

We can now call our effects:

// app.component.ts
@Component({ 
  // ...
})
export class AppComponent {
  private readonly _componentStore = inject(AppComponentStore);
  readonly vm$ = this._componentStore.vm$;

  onPreviousPage(): void {}

  onNextPage(): void {
    this._componentStore.loadNextPage();
  }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, when we visit our page once again, nothing is displayed until onNextPage is displayed.

Indeed, if we look at the lifecycle of our component store, we are not loading anything before the first change is requested.

To solve it, we will proceed in two steps:

The first one is to split the loading of the page and the loading of a different offset into two dedicated effects:

// app.component-store.ts
  readonly loadPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.select((state) => state)),
      map(([, state]) => state),
      switchMap(({ offset, pageSize }) =>
        this._todoItemService.getTodoItems(offset * pageSize, pageSize).pipe(
          tapResponse(
            (todoItems: TodoItem[]) => this.updateTodoItems(todoItems),
            () => console.error("Something went wrong")
          )
        )
      )
    );
  });

  readonly loadNextPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.select((state) => state.offset)),
      map(([, state]) => state),
      tap((offset) => this.updateOffset(offset + 1)),
      tap(() => this.loadPage())
    );
  });
Enter fullscreen mode Exit fullscreen mode

The second is by taking advantage of the NgRx component store lifecycle hooks as described in this article by @brandontroberts

This article tells us that we can perform an action after the state has been initiated or after the store has been initiated.

In our case, loading the first page once the store has been set up sounds like a great option, let's do that!

@Injectable()
export class AppComponentStore
  extends ComponentStore<AppState>
  // πŸ‘‡ Implement the hook
  implements OnStoreInit
{
  private readonly _todoItemService = inject(TodoItemService);

  readonly vm$ = this.select(({ todoItems }) => ({ todoItems }));

  constructor() {
    super(initialState);
  }

  // πŸ‘‡ Load the page once the store is initialized
  ngrxOnStoreInit() {
    this.loadPage();
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

If we refresh our page, we can see that our fix is working and that we have successfully implemented pagination using a component store.

Result

If you would like to go a bit further you can try to:

  • Create the effect loading the previous page
  • Handle offset boundaries (like not going under 0)
  • Add a loading and an error state
  • Change the page size

If you would like to check the resulting code, you can head on to the associated GitHub repository

In a next article, we will see some other usages such as how to take advantage of generics to make this component store reusable across our application, stay tuned!


I hope that you learnt something useful there and as always, happy coding!


Photo by Roman Trifonov on Unsplash

Oldest comments (0)