Earlier I explained how NgRx Component Stores can be used to paginate data.

In this write-up, we’ll approach the identical challenge using StateAdapt, a compact yet expanding state management solution that emphasizes composability, adaptability, and a declarative style.

📑 Like before, this piece is structured as a practical lab. If you’d like to follow along, keep an eye out for the '🧪' markers.


Initial State

Our project is a straightforward todo list that presents a collection of tasks:

Initial State

At this stage, the pagination and data handling still live inside the component, but we're about to move them into a designated store.

Configuring StateAdapt

🧪 If you want to follow along, check out the initial-setup tag to begin from that point.

Before we can manage state, the state management library needs to be set up.

To do this, we need to add the necessary dependencies:

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

After the installation completes, we’re ready to leverage the default store provider:

// 📁 src/main.ts
bootstrapApplication(AppComponent, {
  providers: [defaultStoreProvider],  // 👈 Provided here
}).catch((err) => console.error(err));
Enter fullscreen mode Exit fullscreen mode

Everything is in place—now we're ready to build our adapters!

Creating the adapters

🧪 Check out the with-state-adapt tag to begin from this point

What sets StateAdapt apart is that it lets us specify how each slice of data is handled using dedicated adapters.

For this example, our focus is on managing todo items according to the active pagination.

Rather than tackling the whole state at once, we'll split it up and handle each component individually.

Adapting the Pagination

First, we'll set up an adapter to handle the pagination-related state.

Open a new file and pull out the interface there:

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

At this point, we can go ahead and define the two actions that exist right now: moving to the following page and returning to the previous one:

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

📑 In a production setting, you might want to adjust page size as well, but I’ll keep it simple here!

Adapting the Todo Item

Following the same pattern we used for pagination, we're going to set up an adapter for the TodoItem now.

Since the interface is already defined, we only need to add the adapter on top of it.

To keep this example straightforward, our state will only have a selector for the item, with no extra logic:

// 📁 src/app/todo-item.ts

// ...

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

Now that we've prepared our TodoItem for adaptation, the reality is we won't be working with a single item—multiple items are in play.

While it's possible to build an adapter for TodoItem[], StateAdapt provides a more streamlined alternative for managing collections of entities: createEntityAdapter lets us repurpose existing logic without extra boilerplate.

By applying the adapter we've already set up, adapting a list of todo items becomes straightforward:

// 📁 src/app/todo-item.ts

// ...

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

Because of this, our TodoItem adapters are complete in only a handful of lines. Next, let's turn our attention to the paginated items!

Adapting the Paginated Items

Within our TodoItemService, the state can be shaped as pagination metadata alongside a set of todo items.

Yet, because the TodoItem[] in the list function as entities, we ought to mark them accordingly. StateAdapt offers an EntityState type that accepts two generic parameters—one for the entity and one for the key designating its identity. Handling multiple TodoItems here equates to handling 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

At first glance, this may seem unusual, but it lets us leverage our existing adapters to shape the TodoItemsState via joinAdapters, requiring almost no additional code:

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

With every managed entity and piece of state now open to adaptation, the adapters still need to be applied in order to assemble the store.

To kick things off, we require a baseline, 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

📑 The todoItems slice is typed as EntityState<TodoItem, 'id'>, which is why the createEntityState helper is used to set up the starting value rather than passing in an empty array.

Given that starting point, setting up the store calls for only a minimal amount of additional 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

Now that the state is initialized and the store is in place, we can connect the actions that let us move between pages.

Within StateAdapt, you trigger an action by pushing a value into a Source, which serves as the channel for dispatching actions.

For this scenario, two sources are necessary: one for advancing to the following page, and another for going back to the preceding 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

💡 It's worth noting that StateAdapt generated these sources automatically, thanks to the paginationAdapter being joined!

This approach updates the pagination state, but the TodoItems remain unchanged—a side effect is required to handle them.

Fundamentally, our goal is to fetch and assign the TodoItems whenever the pagination state shifts.

To achieve this, we begin by defining a method to set all TodoItems, mirroring our earlier pagination setup:

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

Triggering a side effect works exactly like any other side effect defined with RxJs: simply subscribe to the relevant 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're nearly finished! The state we've built so far already works as a solid management solution, but there's no way to read it yet — so let's add selectors.

Reading Our State

A common approach to reading state is through view models, often abbreviated as "vm".

For our store, the view model can be a signal that combines the two pieces we care about: the pagination details and the todo list.

// 📁 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 set up and accessible, so it's time to drop the component-level logic and depend on it instead!

Consuming the State

🧪 Check out the with-store tag to begin from this point

Returning to our AppComponent, we can now strip out the manual logic from the code-behind and lean 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

And in the same way, the template is now able to read the vm value directly:

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

🧪 There's room to take this even further—why not shift the logic behind the [disabled] attributes into a selector?

That wraps it up! Now, StateAdapt is in charge of pagination, which opens the door to extra capabilities such as built-in Redux DevTools support:

Finalized

Takeaways

Throughout this piece, we explored pagination handling with StateAdapt serving as our state management tool, leveraging its composition-oriented API.

Our entities were gradually adapted to build the component state, and the store was then seeded from that foundation.

In the end, we retrieved it within our component to strip away the logic it housed.

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








You’ll likely pick up some valuable insights from that resource!


Photo by Sincerely Media on Unsplash