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
- Reactive collections with automatic recomputation
- Efficient filtering/sorting using computed signals
- Built-in pagination with navigation controls
- Selection management for bulk operations
- Type-safe CRUD operations
Next Steps
- Explore State Management Pattern
- Learn about Loading States
- Read Performance Optimization