Table of contents


Bootstrap the Angular project

Our demo will only render a list of todo items split across pages. We will deliberately skip features like toggling, editing, or adding todos since they fall outside the scope of this guide.

The app in this tutorial is built with Angular v15.2, which was the latest release at the time of writing.

Open a fresh directory and run the following command to generate a new project:

ng new --directory ./  --minimal --inline-template --skip-tests

Accept the default prompts, then open the project in your editor. Clear the boilerplate content from app.component.ts and drop in this minimal version instead:

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

Because we’re using standalone components, you can safely delete app.module.ts. Adjust the bootstrap logic in main.ts as shown here:

// 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));

At this point, ng serve should work and we’re ready to move forward.

Emulate a backend

To fetch todo items, we’ll rely on the JSON placeholder API as a stand-in for a real server.

First, define the shape of our data in a new file named todo-item.ts:

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

Next, create a TodoItemService that will be responsible for making the HTTP calls and returning todo items:

// 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 }
    );
  }
}

Create the todo component

Now that our service is ready, we can build a new TodoItemComponent that consumes it and renders each todo item:

// 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;
}

Admittedly, this isn’t the prettiest todo item UI you’ve ever seen.

With all the pieces in place, wire them together to display the first page of results:

// 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();
}

After these changes, your homepage should show a handful of todo items:

homepage v1

Add pagination logic

With the app rendering a few items, let’s shift our attention to navigating between pages. We’ll modify AppComponent minimally to expose previous and next buttons:

// 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 {}
}

The basic solution

A quick and dirty approach is to keep a local variable that tracks pagination state. Every time the user clicks a button, we update that variable and create a new 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);
  }
}

While this works, it’s far from reactive. The logic ends up scattered across event handlers, and we’re forced to manage state imperatively at each step.

There’s definitely room for improvement — let’s explore a cleaner design.

Tapping into subjects

By leveraging an RxJS BehaviorSubject, we can maintain a single source of truth for our pagination state and respond to any updates immediately, refreshing the todo items accordingly:

// 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 approach is an improvement, yet the pagination logic and its associated state still reside inside the component itself.

This scenario is a perfect fit for a component store!

Setting Up the Store

Before we can leverage the component store, the corresponding package has to be installed first:

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

Once that's done, a new file called app.component-store.ts should be created. The state for our feature will be defined right inside this file.

The piece of state we need to hold consists of two parts:

  • The list of TodoItems that should be rendered
  • The pagination configuration, specifically the current offset and the page size
export interface AppState {
  todoItems: TodoItem[];
  offset: number;
  pageSize: number;
}
Enter fullscreen mode Exit fullscreen mode

To make the store usable, we have to provide an initial state that will serve as the starting point:

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

With that initial state defined, we can now instantiate the store and seed it with that data:

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

In order to actually use the store, it needs to be provided to our AppComponent. This is done through the provideComponentStore function:

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

A typical pattern with component stores is to expose a view model, conventionally named vm$, which the component can then subscribe to.

By adopting this approach, we can strip away the old logic that was previously sitting in the 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

That's all we need to get started.

Now that the store is in place, we can migrate our pagination logic into it.

This type of store can be organized much like traditional ones, splitting the code into three distinct areas:

  • Selectors
  • Effects
  • Reducer

In the context of a component store, selectors are often not explicitly defined since the view model takes over that responsibility.

Still, we can trigger effects that will ultimately modify the state of the store.

Following this pattern, let's emit events whenever the offset changes, and adjust the 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 follows a very similar structure, go ahead and write it on your own!

With the effects defined, they can be invoked from the component:

// 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

There's a catch though. When the page is reloaded, nothing shows up until onNextPage is triggered.

If we trace the component store's lifecycle, it becomes clear that no data is being fetched before the first change is requested.

To fix this, we can split the solution into two steps:

The first step involves separating the initial page load from the subsequent offset changes, creating 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 step is to leverage the NgRx component store lifecycle hooks, which are covered in detail in this article by @brandontroberts

According to that resource, it's possible to run an action right after the state has been initialized, or after the store itself has been set up.

Loading the initial page right after the store is initialized seems like the perfect use case — let's implement 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

After refreshing the browser, the fix becomes visible: the pagination setup, powered by the component store, is now working as expected.

Result

If you want to push this further, here are some ideas to explore:

  • Write the effect that handles the previous page
  • Guard against offset boundaries, such as preventing it from dropping below zero
  • Introduce loading and error states to the UI
  • Allow the page size to be modified

The full source code is available in the associated GitHub repository

Stay tuned for the next article, where we'll explore other applications, including how to use generics to build a reusable component store across the entire application.


Thanks for reading, and as always, happy coding!


Photo by Roman Trifonov on Unsplash