DEV Community

Cover image for Handling Pagination with StateAdapt
Pierre Bouillon for This is Angular

Posted on

Handling Pagination with StateAdapt

In a previous article, I covered how to paginate data using NgRx Component Stores

In today's article, we are going to tackle the same problem but with StateAdapt, a small but growing state management library, focused on composability, evolutivity and declarative programming.

๐Ÿ“‘ As usual, this article is build as a hands on lab. You can code alongside me by following the notes marked by a '๐Ÿงช'.


Initial State

The application we will be working on will be a simple todo list, with a bunch of items displayed:

Initial State

For now the pagination is handled by the component itself, along with the data, but we will be shifting this from the component into a dedicated store.

Configuring StateAdapt

๐Ÿงช Checkout the initial-setup tag to get started from here

Before managing our state, we will first need to configure the state management library.

For that, we will install the required packages:

npm i -D @state-adapt/core @state-adapt/rxjs @state-adapt/angular
Enter fullscreen mode Exit fullscreen mode

Once installed, we can use the default store provider:

// ๐Ÿ“ src/main.ts
bootstrapApplication(AppComponent, {
  providers: [defaultStoreProvider],  // ๐Ÿ‘ˆ Provided here
}).catch((err) => console.error(err));
Enter fullscreen mode Exit fullscreen mode

We're all set and ready for to create our adapters!

Creating the adapters

๐Ÿงช Checkout the with-state-adapt tag to get started from here

StateAdapt has the unique particularity of allowing us to define how to manage a specific piece of data through dedicated adapters.

In our case, we will be managing todo items based on the current pagination.

Instead of managing everything at once, we will break it down by managing each piece of part of our state.

Adapting the Pagination

To get started, we will start by creating the adapter of the pagination details.

In a new file, we can extract the interface:

// ๐Ÿ“ src/app/pagination.ts
export interface Pagination {
  offset: number;
  pageSize: number;
}
Enter fullscreen mode Exit fullscreen mode

From there, we will be able to define the two actions we currently have: going to the next and the previous page:

// ๐Ÿ“ src/app/pagination.ts

// ...

export const paginationAdapter = createAdapter<Pagination>()({
  nextPage: ({ offset, pageSize }) => ({ pageSize, offset: offset + 1 }),
  previousPage: ({ offset, pageSize }) => ({ pageSize, offset: offset - 1 }),
  selectors: {
    pagination: (state) => state,
  },
});
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“‘ You might also want to change the page size and more in a real application, I'm just sticking to the example here!

Adapting the Todo Item

As we just did for the pagination, we will now be creating an adapter for our TodoItem.

This time the interface already exists, and we will just append the adapter.

For the example simplicity's sake, our state won't contain any logic, just a selector for the item itself:

// ๐Ÿ“ src/app/todo-item.ts

// ...

export const todoItemAdapter = createAdapter<TodoItem>()({
  selectors: {
    todoItem: (state) => state,
  },
});
Enter fullscreen mode Exit fullscreen mode

We're all set for adapting our TodoItem but unfortunately we won't be manipulating a single todo item but several onces.

We could create an adapter for TodoItem[] but instead StateAdapt offers an efficient and concise way of reusing existing logic for multiple entities with createEntityAdapter.

With our previously defined adapter, we can now adapt a list of todo items fairly simply:

// ๐Ÿ“ src/app/todo-item.ts

// ...

export const todoItemsAdapter = createEntityAdapter<TodoItem>()(todoItemAdapter);
Enter fullscreen mode Exit fullscreen mode

Thanks to that, our adapters for the TodoItem are done, in just a few lines of code. It's time to work on the paginated items now!

Adapting the Paginated Items

Back in our TodoItemService, we can define our state as a pagination details and a collection of todo items.

However, since the listed TodoItem[] are considered as entities, we will tag them as such. StateAdapt provides a type named EntityState that has two generic parameters: the entity itself and the name of the key used for its identity. In our case, managing several TodoItems drills down to managing an EntityState<TodoItem, 'id'>:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

export interface TodoItemsState {
  pagination: Pagination;
  todoItems: EntityState<TodoItem, 'id'>;
}

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

While this might look strange at first, it allows us to reuse or previous adapters to adapt the TodoItemsState using joinAdapters, with little to no code to write:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

export const todoItemsStateAdapter = joinAdapters<TodoItemsState>()({
  pagination: paginationAdapter,
  todoItems: todoItemsAdapter,
})();

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Creating Our Store

๐Ÿงช Checkout the with-adapters tag to get started from here

Now that all managed entities and state can be adapted, we still have to use the adapters to create the store.

First, we need a starting point, an initial state:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

const initialState: Readonly<TodoItemsState> = {
  pagination: { offset: 0, pageSize: 5 },
  todoItems: createEntityState<TodoItem, 'id'>(),
};

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“‘ Since todoItems is an EntityState<TodoItem, 'id'>, we use the provided createEntityState method for the initial value instead of an empty array.

With this initial state, creating our store doesn't require much more code:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  readonly #store = adapt(initialState, {
    adapter: todoItemsStateAdapter,
  });

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

Defining the Actions

With our state initialized and our store set up, we can now wire the actions allowing us to navigate between pages.

In StateAdapt, an action is triggered by nexting a Source that acts as a medium of action emission.

In our case, we will need two source: one to navigate to the next page, and one to navigate to the previous one:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  readonly nextPage$ = new Source('[Todo Items State] Next Page');
  readonly previousPage$ = new Source('[Todo Items State] Previous Page');

  readonly #store = adapt(initialState, {
    adapter: todoItemsStateAdapter,
    sources: {
      previousPaginationPage: this.previousPage$,
      nextPaginationPage: this.nextPage$,
    },
  });

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

๐Ÿ’ก Notice that StateAdapt auto-generated the sources based on the joined paginationAdapter!

While this will update the pagination, the TodoItems won't be updated, and we will need to perform a side effect for that.

In essence, we would like to retrieve and set the TodoItems every time the pagination changes.

To do so, we will first define a way to set all TodoItems, the same way we did for the pagination:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  readonly nextPage$ = new Source('[Todo Items State] Next Page');
  readonly previousPage$ = new Source('[Todo Items State] Previous Page');
  readonly #setTodoItems$ = new Source<TodoItem[]>(
    '[Todo Items State] Set Todo Items'
  );

  readonly #store = adapt(initialState, {
    adapter: todoItemsStateAdapter,
    sources: {
      previousPaginationPage: this.previousPage$,
      nextPaginationPage: this.nextPage$,
      setTodoItemsAll: this.#setTodoItems$,  ๐Ÿ‘ˆ New source
    },
  });

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

Performing a side effect is just as much as any side effect defined using RxJs: by subscribing to the appropriate observable:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  readonly nextPage$ = new Source<void>('[Todo Items State] Next Page');
  readonly previousPage$ = new Source<void>('[Todo Items State] Previous Page');

  // ๐Ÿ‘‡ Since this side effect is internal, the visibility is private
  readonly #setTodoItems$ = new Source<TodoItem[]>('[Todo Items State] Set Todo Items');

  readonly #store = adapt(initialState, {
    // ...
  });

  constructor() {
    this.#store.pagination$
      .pipe(
        takeUntilDestroyed(),
        switchMap((pagination) => this.getTodoItems(pagination))
      )
      .subscribe((todoItems) => this.#setTodoItems$.next(todoItems));
  }

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

We are almost done! Our state is now a functional management solution, but we can't read it for now, let's add the selectors.

Reading Our State

A popular way of reading state is by defining view models, or "vm".

For our store, we can define the view model as a signal of the two things we are interested in: the pagination and the todo items:

// ๐Ÿ“ src/app/todo-item.service.ts

// ...

@Injectable({ providedIn: 'root' })
export class TodoItemService {
  // ...

  readonly vm = toSignal(
    this.#store.state$.pipe(
      map((state) => ({
        pagination: state.pagination,
        todoItems: Object.values(state.todoItems.entities),
      }))
    ),
    { requireSync: true }
  );

  constructor() {
    // ...
  }

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

๐Ÿ“‘ You can also use combineLatest to create your view model:

readonly vm = toSignal(
  combineLatest({
    pagination: this.#store.pagination$,
    todoItems: this.#store.todoItemsAll$,
  }),
  { requireSync: true }
);

Our state is now initialized and readable, it's time to ditch the logic in the component and rely on it instead!

Consuming the State

๐Ÿงช Checkout the with-store tag to get started from here

Back in our AppComponent, we can now remove the custom logic from the code behind and rely on the service instead:

// ๐Ÿ“ src/app/app.component.ts

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

  readonly vm = this.#todoItemService.vm;

  onPreviousPage(): void {
    this.#todoItemService.previousPage$.next();
  }

  onNextPage(): void {
    this.#todoItemService.nextPage$.next();
  }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, we can now consume the vm from the template:

// ๐Ÿ“ src/app/app.component.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [TodoItemComponent],
  template: `
    @for (todoItem of vm().todoItems; track todoItem.id) {
    <app-todo-item [todoItem]="todoItem" />
    }

    <div class="grid">
      <button
        type="button"
        (click)="onPreviousPage()"
        [disabled]="vm().pagination.offset === 0"
      >
        โ†
      </button>
      <button
        type="button"
        (click)="onNextPage()"
        [disabled]="vm().pagination.offset === 2"
      >
        โ†’
      </button>
    </div>
  `,
})
export class AppComponent {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงช You could still go further, what about moving the condition for the [disabled] attributes to a selector?

And we are done! However, our pagination is now handled by StateAdapt that will also give us access to additional features like Redux DevTools out of the box:

Finalized

Takeaways

In this article, we saw how we could handle pagination using StateAdapt as our state management solution, by taking advantage of its API focused on composition.

We incrementally adapted our entities to create the component state, and initialized our store from that point.

Finally, we consumed it from our component in order to remove the logic that it defined.

If you would like to see the resulting code, you can browse the article's repository:

GitHub logo pBouillon / DEV.HandlingPaginationWithStateAdapt

Demo code for the "Handling pagination with StateAdapt" article on DEV

Handling pagination with StateAdapt

Demo code for the "Handling pagination with StateAdapt" article on DEV



I hope that you learn something useful there!


Photo by Sincerely Media on Unsplash

Top comments (0)