In our earlier piece, we built a component store with NgRx specifically for paginating a list of todo items.

That design, however, is tightly coupled to our particular domain, so now we need to craft a more general solution—one we can drop onto any other entity we might want to paginate in the same manner.


Table of content


Creating our new store

We start by setting up a fresh paginated-items.component-store.ts file that will house all the pagination logic.

The state

Our first step is to pin down the state, as that defines everything else in the store.

Below is what the state looks like right now:

export interface AppState {
  todoItems: TodoItem[];
  offset: number;
  pageSize: number;
}
Enter fullscreen mode Exit fullscreen mode

Its properties fall into two distinct groups:

  • Those tied to pagination (offset, pageSize)
  • Those tied to the data itself (here, just todoItems)

This leads us to pull out a new interface that encapsulates the pagination-specific parameters:

export interface PaginationDetails {
  offset: number;
  pageSize: number;
}
Enter fullscreen mode Exit fullscreen mode

Here is another one that wraps the page content:

export interface PageContent {
  todoItems: TodoItem[];
}
Enter fullscreen mode Exit fullscreen mode

With these two interfaces in place, the state we’re working with becomes noticeably clearer:

export interface PaginatedItemsState {
  paginationDetails: PaginationDetails;
  pageContent: PageContent;
}
Enter fullscreen mode Exit fullscreen mode

Now, let's dive right in!

The store itself

Setting the foundations

We'll make this store an abstract class, giving every derived store the flexibility to implement its own behaviors.

With the state we defined earlier, the code becomes:

@Injectable()
// 👇 Beware not to forget the `abstract` here
export abstract class PaginatedItemsComponentStore
  extends ComponentStore<PaginatedItemsState> { }
Enter fullscreen mode Exit fullscreen mode

Creating selectors

State management is nice, but what truly matters is reading the data inside it. So let's bring in selectors.

Begin with a few foundational selectors that fetch every individual field:

@Injectable()
export abstract class PaginatedItemsComponentStore
  extends ComponentStore<PaginatedItemsState>
{
  readonly selectPaginatedItemsState = this.select((state) => state);

  readonly selectPaginationDetails = this.select(
    this.selectPaginatedItemsState,
    ({ paginationDetails }) => paginationDetails
  );

  readonly selectOffset = this.select(
    this.selectPaginationDetails,
    ({ offset }) => offset
  );

  readonly selectPageSize = this.select(
    this.selectPaginationDetails,
    ({ pageSize }) => pageSize
  );

  readonly selectPageContent = this.select(
    this.selectPaginatedItemsState,
    ({ pageContent }) => pageContent
  );

  readonly selectTodoItems = this.select(
    this.selectPageContent,
    ({ todoItems }) => todoItems
  );
}
Enter fullscreen mode Exit fullscreen mode

While this list of selectors might seem extensive, they exist so that derived stores can rely on these built-in utilities instead of implementing similar logic from scratch.

Handling pagination

Within our store, we now introduce updaters to modify the state:

@Injectable()
export abstract class PaginatedItemsComponentStore
  extends ComponentStore<PaginatedItemsState>
{
  /* Selectors omitted here */

  private readonly updatePagination = this.updater(
    (state, paginationDetails: PaginationDetails) => ({ ...state, paginationDetails })
  );

  private readonly updatePaginatedItems = this.updater(
    (state, pageContent: PageContent) => ({ ...state, pageContent })
  );
}
Enter fullscreen mode Exit fullscreen mode

If you need it, I'm using one store for the pagination state and another for the paginated data—though you're welcome to make the split coarser or finer.

At last, we're able to reuse the loadPage as well as the loadNextPage effects from the earlier AppComponentStore, and just tweak them as needed:

@Injectable()
export abstract class PaginatedItemsComponentStore
  extends ComponentStore<PaginatedItemsState>
{
  /* Selectors omitted here */

  // 👇 Don't forget to also inject our service
  private readonly _todoItemService = inject(TodoItemService);

  readonly loadPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      // 👇 We can directly access our pagination details from our selector
      withLatestFrom(this.selectPaginationDetails),
      switchMap(([, { offset, pageSize }]) =>
        this._todoItemService.getTodoItems(offset, pageSize).pipe(
          tapResponse(
            (todoItems: TodoItem[]) => this.updatePaginatedItems({ todoItems }),
            () => console.error("Something went wrong")
          )
        )
      )
    );
  });

  readonly loadNextPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      // 👇 Same here
      withLatestFrom(this.selectPaginationDetails),
      tap(([, { offset, pageSize }]) => this.updatePagination({
        offset: offset + pageSize,
        pageSize
      })),
      tap(() => this.loadPage())
    );
  });

  /* Updaters omitted here */
}
Enter fullscreen mode Exit fullscreen mode

Using NgRx lifecycle hooks

Just like in the initial implementation, the OnStoreInit lifecycle hook remains available in this context, allowing us to trigger the initial page load when the store is created:

@Injectable()
export abstract class PaginatedItemsComponentStore
  extends ComponentStore<PaginatedItemsState>
  implements OnStoreInit
{
  ngrxOnStoreInit() {
    this.loadPage();
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach guarantees that every store responsible for paginating items—and therefore extending this base store—triggers the initial page load automatically at the moment it is instantiated.

Extending our store

At this point, the only remaining step is for our AppComponentStore to extend PaginatedItemsComponentStore rather than ComponentStore<AppState>

To accomplish this, we must modify the initial state and discard our custom AppState in favor of the pre-defined PaginatedItemsState:

// app.component-store.ts
const initialState: PaginatedItemsState = {
  paginationDetails: {
    offset: 0,
    pageSize: 10,
  },
  pageContent: {
    todoItems: [],
  },
};
Enter fullscreen mode Exit fullscreen mode

With that in place, every updater, effect, and lifecycle hook can be removed from the component store. The behavior is now inherited from PaginatedComponentStore:

@Injectable()
export class AppComponentStore
  extends PaginatedItemsComponentStore
{
  readonly vm$ = this.select(
    this.selectTodoItems,
    (todoItems) => ({ todoItems }));

  constructor() {
    super(initialState);
  }
}
Enter fullscreen mode Exit fullscreen mode

Once you launch the app after making these adjustments, you’ll notice that its behavior remains unchanged — a win!

Introducing generics

Everything looks solid, but hold off on celebrating

The only reason our transition was a straightforward copy-paste from the old store is that we locked it down to TodoItems

In actual apps, odds are you’ll need to paginate more than just one kind of entity

The good news is that TypeScript offers solid support for generics, and we can tap into that to boost the flexibility of our PaginatedItemsComponentStore

Rework our state

The PageContent interface is where we should focus our attention first

Right now, it fixes the content to a TodoItem array, but we’re aiming to accommodate a wider range of types

Switching it to any[] might seem like an easy fix, but given that we’re working with _Type_Script, not _Any_Script, there’s a more elegant route

With generics, we can tell our interface that it holds an array of items with an unspecified type, which we’ll call TItem for now:

export interface PageContent<TItem> {
  // 👇 Since we are manipulating items now I renamed the property
  items: TItem[];
}
Enter fullscreen mode Exit fullscreen mode

As a rule of thumb, every generic type I introduce begins with the letter T, which is then followed by its descriptive purpose

Because the generics must be carried through, the PaginatedItemsState definition also has to be revised to keep everything aligned:

export interface PaginatedItemsState<TItem> {
  paginationDetails: PaginationDetails;
  pageContent: PageContent<TItem>;
}
Enter fullscreen mode Exit fullscreen mode

Updating our store

After adjusting the state, the current store implementation no longer works, and we must propagate the generic parameter to it as well.

Still, we want to avoid locking in a specific data type—otherwise, all the refactoring we have done so far loses its purpose.

To handle the initial compile issue, we start by telling our store that TItem is expected to be used:

@Injectable()
export abstract class PaginatedItemsComponentStore<TItem>
  extends ComponentStore<PaginatedItemsState<TItem>>
  implements OnStoreInit { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Once that is done, a couple of minor issues remain to be handled:

  • Inside the selectTodoItems selector, the todoItems property is gone because we renamed it to items. The fix is simply to reference the new property name:
  readonly selectItems = this.select(
    this.selectPageContent,
    ({ items }) => items
  );
Enter fullscreen mode Exit fullscreen mode
  • After the change to updatePaginatedItems, the PageContent loses its knowledge of the generic type, so we have to provide it explicitly:
  private readonly updatePaginatedItems = this.updater(
    (state, pageContent: PageContent<TItem>) => ({ ...state, pageContent })
  );
Enter fullscreen mode Exit fullscreen mode

Now a larger problem presents itself: within the loadPage effect, we invoke the todoItemService, which is strictly tied to our TodoItems

Delegate the fetching logic

Within the PaginatedItemsComponentStore, we cannot predict how a particular type of TItem will be fetched, given an offset and page size

The subclass is nevertheless the one placed to understand that

Since this is an abstract class, we can split the implementation between child classes by declaring an abstract method:

protected abstract getItems(paginationDetails: PaginationDetails): Observable<TItem[]>;
Enter fullscreen mode Exit fullscreen mode

With this approach, we can delete the service instance and substitute its invocation with the abstract method:

-  private readonly _todoItemService = inject(TodoItemService);

  readonly loadPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.selectPaginationDetails),
      switchMap(([, { offset, pageSize }]) =>
-       this._todoItemService.getTodoItems(offset, pageSize).pipe(
+       this.getItems({ offset, pageSize }).pipe(
          tapResponse(
-           (todoItems: TodoItem[]) => this.updatePaginatedItems({ todoItems }),
+           (items: TItem[]) => this.updatePaginatedItems({ items }),
            () => console.error("Something went wrong")
          )
        )
      )
    );
  });
Enter fullscreen mode Exit fullscreen mode

Updating the AppComponentStore

We're nearly finished! Since our base store now works with any type, we must tell our AppComponentStore that we want TItem to stand in for TodoItem.

// 👇 Notice that we are now talking about `TodoItem`
const initialState: PaginatedItemsState<TodoItem> = {
  paginationDetails: {
    offset: 0,
    pageSize: 10,
  },
  pageContent: {
    items: [],
  },
};

@Injectable()
export class AppComponentStore
  // 👇 Same here
  extends PaginatedItemsComponentStore<TodoItem>
{
  readonly vm$ = this.select(
    // 👇 Don't forget that our selector has been renamed
    this.selectItems,
    (todoItems) => ({ todoItems }));
}
Enter fullscreen mode Exit fullscreen mode

At this point, we still have to provide the getItems logic ourselves so the parent component can obtain the TodoItem list.

To do that, we must inject a TodoItemService again and invoke it within that method:

  private readonly _todoItemService = inject(TodoItemService);

  protected getItems({ offset, pageSize }: PaginationDetails): Observable<TodoItem[]> {
    return this._todoItemService.getTodoItems(offset, pageSize);
  }
Enter fullscreen mode Exit fullscreen mode

Once the application is rebuilt, the existing functionality should remain unchanged — but now paginating a different entity type no longer requires reimplementing the entire component store from scratch.


The approach above demonstrates how generics can be used to extract the shared pagination behavior into an abstract component, ready for subclassing in future scenarios.

For those interested in extending the concept further, consider these potential enhancements:

  • Incorporate loading state and error handling mechanisms
  • Introduce additional selectors, such as retrieving the first item on the current page
  • Develop a PostService with structure similar to TodoItemService, but fetching from https://jsonplaceholder.typicode.com/posts; then, establish a new component store that inherits from PaginatedItemsComponentStore<Post> to paginate Post entries instead of TodoItem ones

To view the final implementation, visit the associated GitHub repository


We trust this has provided valuable insights — and as always, happy coding!


Photo by Roman Trifonov on Unsplash