Data Management intermediate

Signal Collections Manager

Manage lists, filtering, sorting, and pagination with signals for efficient collection handling.

Alex Muturi
July 10, 2025
18 min read
#signals #collections #filtering #sorting #pagination #crud

Signal Collections Manager

Managing collections of data is a common task in Angular applications. This recipe provides a powerful, reusable solution for handling lists with filtering, sorting, pagination, and CRUD operations using signals.

Complete Solution

import { signal, computed, Signal } from '@angular/core';

export interface CollectionOptions<T> {
  items: T[];
  pageSize?: number;
  sortBy?: keyof T;
  sortOrder?: 'asc' | 'desc';
}

export class SignalCollection<T extends { id: string | number }> {
  // Core state
  private rawItems = signal<T[]>([]);
  private filterFn = signal<((item: T) => boolean) | null>(null);
  private sortKey = signal<keyof T | null>(null);
  private sortOrder = signal<'asc' | 'desc'>('asc');
  private pageSize = signal(10);
  private currentPage = signal(1);
  private selectedIds = signal<Set<string | number>>(new Set());

  // Computed: Filtered items
  readonly filteredItems = computed(() => {
    const items = this.rawItems();
    const filter = this.filterFn();
    return filter ? items.filter(filter) : items;
  });

  // Computed: Sorted items
  readonly sortedItems = computed(() => {
    const items = [...this.filteredItems()];
    const key = this.sortKey();
    const order = this.sortOrder();

    if (!key) return items;

    return items.sort((a, b) => {
      const aVal = a[key];
      const bVal = b[key];

      if (aVal < bVal) return order === 'asc' ? -1 : 1;
      if (aVal > bVal) return order === 'asc' ? 1 : -1;
      return 0;
    });
  });

  // Computed: Paginated items
  readonly paginatedItems = computed(() => {
    const items = this.sortedItems();
    const size = this.pageSize();
    const page = this.currentPage();
    const start = (page - 1) * size;
    return items.slice(start, start + size);
  });

  // Computed: Pagination info
  readonly totalItems = computed(() => this.sortedItems().length);
  readonly totalPages = computed(() => 
    Math.ceil(this.totalItems() / this.pageSize())
  );
  readonly hasNextPage = computed(() => 
    this.currentPage() < this.totalPages()
  );
  readonly hasPrevPage = computed(() => this.currentPage() > 1);

  // Computed: Selection
  readonly selectedItems = computed(() => {
    const ids = this.selectedIds();
    return this.rawItems().filter(item => ids.has(item.id));
  });
  readonly selectionCount = computed(() => this.selectedIds().size);
  readonly allSelected = computed(() => {
    const ids = this.selectedIds();
    const pageItems = this.paginatedItems();
    return pageItems.length > 0 && 
           pageItems.every(item => ids.has(item.id));
  });

  constructor(options: CollectionOptions<T>) {
    this.rawItems.set(options.items);
    if (options.pageSize) this.pageSize.set(options.pageSize);
    if (options.sortBy) this.sortKey.set(options.sortBy);
    if (options.sortOrder) this.sortOrder.set(options.sortOrder);
  }

  // CRUD operations
  add(item: T): void {
    this.rawItems.update(items => [...items, item]);
  }

  addMany(newItems: T[]): void {
    this.rawItems.update(items => [...items, ...newItems]);
  }

  update(id: string | number, updates: Partial<T>): void {
    this.rawItems.update(items =>
      items.map(item => item.id === id ? { ...item, ...updates } : item)
    );
  }

  remove(id: string | number): void {
    this.rawItems.update(items => items.filter(item => item.id !== id));
    this.selectedIds.update(ids => {
      const newIds = new Set(ids);
      newIds.delete(id);
      return newIds;
    });
  }

  removeMany(ids: (string | number)[]): void {
    const idsSet = new Set(ids);
    this.rawItems.update(items => 
      items.filter(item => !idsSet.has(item.id))
    );
    this.selectedIds.update(selected => {
      const newIds = new Set(selected);
      ids.forEach(id => newIds.delete(id));
      return newIds;
    });
  }

  clear(): void {
    this.rawItems.set([]);
    this.selectedIds.set(new Set());
    this.currentPage.set(1);
  }

  // Filtering
  setFilter(fn: ((item: T) => boolean) | null): void {
    this.filterFn.set(fn);
    this.currentPage.set(1); // Reset to first page
  }

  clearFilter(): void {
    this.filterFn.set(null);
  }

  // Sorting
  setSortBy(key: keyof T, order: 'asc' | 'desc' = 'asc'): void {
    this.sortKey.set(key);
    this.sortOrder.set(order);
  }

  toggleSort(key: keyof T): void {
    if (this.sortKey() === key) {
      this.sortOrder.update(order => order === 'asc' ? 'desc' : 'asc');
    } else {
      this.sortKey.set(key);
      this.sortOrder.set('asc');
    }
  }

  clearSort(): void {
    this.sortKey.set(null);
  }

  // Pagination
  setPage(page: number): void {
    const maxPage = this.totalPages();
    this.currentPage.set(Math.max(1, Math.min(page, maxPage)));
  }

  nextPage(): void {
    if (this.hasNextPage()) {
      this.currentPage.update(p => p + 1);
    }
  }

  prevPage(): void {
    if (this.hasPrevPage()) {
      this.currentPage.update(p => p - 1);
    }
  }

  setPageSize(size: number): void {
    this.pageSize.set(Math.max(1, size));
    this.currentPage.set(1);
  }

  // Selection
  select(id: string | number): void {
    this.selectedIds.update(ids => new Set(ids).add(id));
  }

  deselect(id: string | number): void {
    this.selectedIds.update(ids => {
      const newIds = new Set(ids);
      newIds.delete(id);
      return newIds;
    });
  }

  toggleSelect(id: string | number): void {
    this.selectedIds.update(ids => {
      const newIds = new Set(ids);
      if (newIds.has(id)) {
        newIds.delete(id);
      } else {
        newIds.add(id);
      }
      return newIds;
    });
  }

  selectAll(): void {
    this.selectedIds.set(new Set(this.paginatedItems().map(item => item.id)));
  }

  deselectAll(): void {
    this.selectedIds.set(new Set());
  }

  toggleSelectAll(): void {
    if (this.allSelected()) {
      this.deselectAll();
    } else {
      this.selectAll();
    }
  }

  isSelected(id: string | number): Signal<boolean> {
    return computed(() => this.selectedIds().has(id));
  }
}

Usage Example: User Management

import { Component, signal, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SignalCollection } from './signal-collection';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  status: 'active' | 'inactive';
  createdAt: Date;
}

@Component({
  selector: 'app-user-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  template: `
    <div class="user-management">
      <div class="toolbar">
        <input
          type="text"
          [(ngModel)]="searchQuery"
          (ngModelChange)="onSearchChange($event)"
          placeholder="Search users..."
          class="search-input"
        />

        <select [(ngModel)]="roleFilter" (ngModelChange)="applyFilters()">
          <option value="">All Roles</option>
          <option value="admin">Admin</option>
          <option value="user">User</option>
          <option value="guest">Guest</option>
        </select>

        <select [(ngModel)]="statusFilter" (ngModelChange)="applyFilters()">
          <option value="">All Status</option>
          <option value="active">Active</option>
          <option value="inactive">Inactive</option>
        </select>

        @if (users.selectionCount() > 0) {
          <button class="danger" (click)="deleteSelected()">
            Delete Selected ({{ users.selectionCount() }})
          </button>
        }

        <button (click)="addRandomUser()">Add User</button>
      </div>

      <div class="table-container">
        <table>
          <thead>
            <tr>
              <th>
                <input
                  type="checkbox"
                  [checked]="users.allSelected()"
                  (change)="users.toggleSelectAll()"
                />
              </th>
              <th (click)="users.toggleSort('name')" class="sortable">
                Name {{ getSortIcon('name') }}
              </th>
              <th (click)="users.toggleSort('email')" class="sortable">
                Email {{ getSortIcon('email') }}
              </th>
              <th (click)="users.toggleSort('role')" class="sortable">
                Role {{ getSortIcon('role') }}
              </th>
              <th>Status</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            @for (user of users.paginatedItems(); track user.id) {
              <tr [class.selected]="users.isSelected(user.id)()">
                <td>
                  <input
                    type="checkbox"
                    [checked]="users.isSelected(user.id)()"
                    (change)="users.toggleSelect(user.id)"
                  />
                </td>
                <td>{{ user.name }}</td>
                <td>{{ user.email }}</td>
                <td>
                  <span class="badge" [attr.data-role]="user.role">
                    {{ user.role }}
                  </span>
                </td>
                <td>
                  <span class="status" [attr.data-status]="user.status">
                    {{ user.status }}
                  </span>
                </td>
                <td>
                  <button (click)="editUser(user)">Edit</button>
                  <button class="danger" (click)="users.remove(user.id)">
                    Delete
                  </button>
                </td>
              </tr>
            }
          </tbody>
        </table>
      </div>

      <div class="pagination">
        <div class="pagination-info">
          Showing {{ getPageStart() }}-{{ getPageEnd() }} of {{ users.totalItems() }}
        </div>

        <div class="pagination-controls">
          <button
            (click)="users.prevPage()"
            [disabled]="!users.hasPrevPage()"
          >
            Previous
          </button>

          @for (page of getPageNumbers(); track page) {
            <button
              (click)="users.setPage(page)"
              [class.active]="isCurrentPage(page)"
            >
              {{ page }}
            </button>
          }

          <button
            (click)="users.nextPage()"
            [disabled]="!users.hasNextPage()"
          >
            Next
          </button>

          <select
            [(ngModel)]="pageSize"
            (ngModelChange)="users.setPageSize($event)"
            class="page-size-select"
          >
            <option [value]="5">5 per page</option>
            <option [value]="10">10 per page</option>
            <option [value]="25">25 per page</option>
            <option [value]="50">50 per page</option>
          </select>
        </div>
      </div>
    </div>
  `,
  styles: [`
    .user-management {
      padding: 20px;
    }

    .toolbar {
      display: flex;
      gap: 12px;
      margin-bottom: 20px;
      flex-wrap: wrap;
    }

    .search-input {
      flex: 1;
      min-width: 200px;
      padding: 8px 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
    }

    select {
      padding: 8px 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
    }

    button {
      padding: 8px 16px;
      background: #2196f3;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    button.danger {
      background: #f44336;
    }

    .table-container {
      overflow-x: auto;
      border: 1px solid #ddd;
      border-radius: 4px;
    }

    table {
      width: 100%;
      border-collapse: collapse;
    }

    th, td {
      padding: 12px;
      text-align: left;
      border-bottom: 1px solid #ddd;
    }

    th {
      background: #f5f5f5;
      font-weight: 600;
    }

    th.sortable {
      cursor: pointer;
      user-select: none;
    }

    th.sortable:hover {
      background: #e0e0e0;
    }

    tr.selected {
      background: #e3f2fd;
    }

    .badge {
      padding: 4px 8px;
      border-radius: 12px;
      font-size: 12px;
      font-weight: 500;
    }

    .badge[data-role="admin"] {
      background: #ff9800;
      color: white;
    }

    .badge[data-role="user"] {
      background: #2196f3;
      color: white;
    }

    .badge[data-role="guest"] {
      background: #9e9e9e;
      color: white;
    }

    .status[data-status="active"] {
      color: #4caf50;
      font-weight: 500;
    }

    .status[data-status="inactive"] {
      color: #f44336;
      font-weight: 500;
    }

    .pagination {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-top: 20px;
      padding: 12px;
      background: #f5f5f5;
      border-radius: 4px;
    }

    .pagination-controls {
      display: flex;
      gap: 4px;
      align-items: center;
    }

    .pagination-controls button {
      min-width: 40px;
    }

    .pagination-controls button.active {
      background: #1976d2;
    }

    .page-size-select {
      margin-left: 12px;
    }
  `]
})
export class UserListComponent {
  users = new SignalCollection<User>({
    items: this.generateMockUsers(),
    pageSize: 10,
    sortBy: 'name',
    sortOrder: 'asc'
  });

  searchQuery = '';
  roleFilter = '';
  statusFilter = '';
  pageSize = 10;

  generateMockUsers(): User[] {
    const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry'];
    const roles: User['role'][] = ['admin', 'user', 'guest'];
    const statuses: User['status'][] = ['active', 'inactive'];

    return Array.from({ length: 50 }, (_, i) => ({
      id: i + 1,
      name: names[i % names.length] + ' ' + (Math.floor(i / names.length) + 1),
      email: `user${i + 1}@example.com`,
      role: roles[i % roles.length],
      status: statuses[i % 2],
      createdAt: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000)
    }));
  }

  onSearchChange(query: string) {
    this.applyFilters();
  }

  applyFilters() {
    const query = this.searchQuery.toLowerCase();
    const role = this.roleFilter;
    const status = this.statusFilter;

    this.users.setFilter(user => {
      const matchesSearch = !query ||
        user.name.toLowerCase().includes(query) ||
        user.email.toLowerCase().includes(query);

      const matchesRole = !role || user.role === role;
      const matchesStatus = !status || user.status === status;

      return matchesSearch && matchesRole && matchesStatus;
    });
  }

  getSortIcon(key: keyof User): string {
    // Implementation for sort icons
    return '';
  }

  deleteSelected() {
    const ids = this.users.selectedItems().map(u => u.id);
    this.users.removeMany(ids);
  }

  addRandomUser() {
    const id = Math.max(...this.users.filteredItems().map(u => Number(u.id)), 0) + 1;
    this.users.add({
      id,
      name: `New User ${id}`,
      email: `newuser${id}@example.com`,
      role: 'user',
      status: 'active',
      createdAt: new Date()
    });
  }

  editUser(user: User) {
    console.log('Edit user:', user);
  }

  getPageStart(): number {
    return (this.users['currentPage']() - 1) * this.users['pageSize']() + 1;
  }

  getPageEnd(): number {
    return Math.min(
      this.users['currentPage']() * this.users['pageSize'](),
      this.users.totalItems()
    );
  }

  getPageNumbers(): number[] {
    return Array.from({ length: this.users.totalPages() }, (_, i) => i + 1);
  }

  isCurrentPage(page: number): boolean {
    return page === this.users['currentPage']();
  }
}

Key Takeaways

  1. Reactive collections with automatic recomputation
  2. Efficient filtering/sorting using computed signals
  3. Built-in pagination with navigation controls
  4. Selection management for bulk operations
  5. Type-safe CRUD operations

Next Steps

References

Share this article