The Angular task list feature is the centrepiece of the user interface — the component where users spend most of their time. It must handle loading states, empty states, error states, pagination, filtering, sorting, real-time updates arriving via Socket.io, optimistic mutations, and the task card interactions (complete, delete, assign, edit) with per-item loading feedback. This lesson implements the complete task list feature using Angular’s signal-based state management, demonstrating how the patterns from Chapters 10–18 combine into a production-quality feature.
Task List Component Architecture
| Component | Type | Responsibility |
|---|---|---|
TaskListPageComponent |
Smart (container) | Owns TaskStore, reads URL params, drives filters |
TaskFilterBarComponent |
Presentational | Status/priority dropdowns, search input, sort selector |
TaskListComponent |
Presentational | Renders list, handles virtual scroll for 1000+ tasks |
TaskCardComponent |
Presentational | Single task row — title, badges, assignees, actions |
TaskPaginationComponent |
Presentational | Page controls from meta signal |
TaskEmptyStateComponent |
Presentational | Illustration + CTA when filtered list is empty |
TaskStore is the single source of truth for task state in the Angular application. Components do not hold task data in local component state — they read from store signals and dispatch actions to the store. This means any component in the feature can display the current task list, and the real-time Socket.io updates in the store are automatically reflected in all components because they all read from the same signals. This is the unidirectional data flow pattern.takeUntilDestroyed() from @angular/core/rxjs-interop instead of manually managing subscriptions with Subject + takeUntil. takeUntilDestroyed() automatically completes observables when the component or service is destroyed, working correctly with both components and services. For route param subscriptions, Socket.io observables, and any other RxJS stream in a component, append .pipe(takeUntilDestroyed()) and the subscription is cleaned up automatically on component destroy.@for loops in Angular templates require a track expression for performance — without it, Angular recreates the entire list DOM on every change, causing animation flashes and losing focus state. Always track by the stable unique identifier: @for (task of tasks(); track task._id). For optimistic tasks with temporary IDs (temp_xyz), the temporary ID is stable for the duration of the optimistic state and will be replaced by the real ID when the server responds.Complete Task List Feature
// ── features/tasks/stores/task.store.ts ──────────────────────────────────
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TaskService } from '../services/task.service';
import { SocketService } from '../../../core/services/socket.service';
import { ToastService } from '../../../core/services/toast.service';
import { Task, CreateTaskDto, UpdateTaskDto, TaskQueryParams } from '@taskmanager/shared';
import { PaginationMeta } from '../../../core/services/api.service';
import { SOCKET_EVENTS } from '@taskmanager/shared';
type LoadState = 'idle' | 'loading' | 'loaded' | 'error';
@Injectable({ providedIn: 'root' })
export class TaskStore {
private taskService = inject(TaskService);
private socketService = inject(SocketService);
private toast = inject(ToastService);
private destroyRef = inject(DestroyRef);
// ── State ──────────────────────────────────────────────────────────────
private _tasks = signal<Task[]>([]);
private _state = signal<LoadState>('idle');
private _error = signal<string | null>(null);
private _meta = signal<PaginationMeta | null>(null);
private _pending = signal<Set<string>>(new Set());
private _params = signal<TaskQueryParams>({});
// ── Public read-only ───────────────────────────────────────────────────
readonly tasks = this._tasks.asReadonly();
readonly state = this._state.asReadonly();
readonly meta = this._meta.asReadonly();
readonly error = this._error.asReadonly();
readonly params = this._params.asReadonly();
readonly isLoading = computed(() => this._state() === 'loading');
readonly isLoaded = computed(() => this._state() === 'loaded');
readonly isEmpty = computed(() => this.isLoaded() && this._tasks().length === 0);
readonly hasError = computed(() => this._state() === 'error');
readonly isPending = (id: string) => computed(() => this._pending().has(id));
readonly overdueTasks = computed(() =>
this._tasks().filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
)
);
// ── Actions ───────────────────────────────────────────────────────────
load(params: TaskQueryParams): void {
this._params.set(params);
this._state.set('loading');
this._error.set(null);
this.taskService.getAll(params).subscribe({
next: ({ data, meta }) => {
this._tasks.set(data);
this._meta.set(meta);
this._state.set('loaded');
},
error: err => {
this._error.set(err.error?.message ?? 'Failed to load tasks');
this._state.set('error');
},
});
}
createOptimistic(dto: CreateTaskDto): void {
const tempId = `temp_${Date.now()}`;
const optimistic = {
_id: tempId, ...dto,
status: 'todo' as const, priority: dto.priority ?? 'medium',
tags: dto.tags ?? [], attachments: [], assignees: [],
workspace: dto.workspaceId, createdBy: '',
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
isOverdue: false, _optimistic: true,
} as any;
this._tasks.update(tasks => [optimistic, ...tasks]);
this.taskService.create(dto).subscribe({
next: task => {
this._tasks.update(tasks => tasks.map(t => t._id === tempId ? task : t));
this.toast.success('Task created');
},
error: err => {
this._tasks.update(tasks => tasks.filter(t => t._id !== tempId));
this.toast.error(err.error?.message ?? 'Failed to create task');
},
});
}
updateOptimistic(id: string, dto: UpdateTaskDto): void {
const previous = this._tasks();
this._setPending(id, true);
this._tasks.update(tasks =>
tasks.map(t => t._id === id ? { ...t, ...dto } : t)
);
this.taskService.update(id, dto).subscribe({
next: updated => {
this._tasks.update(tasks => tasks.map(t => t._id === id ? updated : t));
this._setPending(id, false);
},
error: err => {
this._tasks.set(previous);
this._setPending(id, false);
this.toast.error(err.error?.message ?? 'Failed to update task');
},
});
}
deleteOptimistic(id: string): void {
const previous = this._tasks();
this._tasks.update(tasks => tasks.filter(t => t._id !== id));
this.taskService.delete(id).subscribe({
error: err => {
this._tasks.set(previous);
this.toast.error(err.error?.message ?? 'Failed to delete task');
},
});
}
// ── Real-time integration ─────────────────────────────────────────────
connectRealTime(workspaceId: string): void {
this.socketService.connect();
this.socketService.joinWorkspace(workspaceId);
this.socketService.on<{ task: Task }>(SOCKET_EVENTS.TASK_CREATED)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ task }) => {
// Only add if not already optimistic version
this._tasks.update(tasks =>
tasks.some(t => t._id === task._id)
? tasks.map(t => t._id === task._id ? task : t)
: [task, ...tasks]
);
});
this.socketService.on<{ taskId: string; changes: Partial<Task> }>(SOCKET_EVENTS.TASK_UPDATED)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ taskId, changes }) => {
this._tasks.update(tasks =>
tasks.map(t => t._id === taskId ? { ...t, ...changes } : t)
);
});
this.socketService.on<{ taskId: string }>(SOCKET_EVENTS.TASK_DELETED)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ taskId }) => {
this._tasks.update(tasks => tasks.filter(t => t._id !== taskId));
});
}
private _setPending(id: string, v: boolean): void {
this._pending.update(s => {
const n = new Set(s); v ? n.add(id) : n.delete(id); return n;
});
}
}
// ── features/tasks/components/task-list-page/task-list-page.component.ts ──
@Component({
selector: 'tm-task-list-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TaskFilterBarComponent, TaskListComponent, TaskPaginationComponent,
TaskEmptyStateComponent, SpinnerComponent, ErrorStateComponent],
template: `
<div class="task-list-page">
<tm-task-filter-bar
[params]="store.params()"
(paramsChange)="onParamsChange($event)">
</tm-task-filter-bar>
@if (store.isLoading()) {
<tm-spinner message="Loading tasks..."></tm-spinner>
} @else if (store.hasError()) {
<tm-error-state [message]="store.error()!" (retry)="reload()"></tm-error-state>
} @else if (store.isEmpty()) {
<tm-task-empty-state [filtered]="hasFilters()"
(createTask)="onCreateTask()"></tm-task-empty-state>
} @else {
<tm-task-list
[tasks]="store.tasks()"
[pendingIds]="pendingIds()"
(complete)="onComplete($event)"
(delete)="onDelete($event)"
(edit)="onEdit($event)">
</tm-task-list>
<tm-task-pagination
[meta]="store.meta()"
(pageChange)="onPageChange($event)">
</tm-task-pagination>
}
</div>
`,
})
export class TaskListPageComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
store = inject(TaskStore);
private workspaceId = '';
readonly pendingIds = computed(() => this.store['_pending']());
readonly hasFilters = computed(() => {
const p = this.store.params();
return !!(p.status || p.priority || p.q || p.assignee);
});
ngOnInit(): void {
this.route.params.pipe(takeUntilDestroyed()).subscribe(params => {
this.workspaceId = params['workspaceId'];
this.store.connectRealTime(this.workspaceId);
});
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe(qp => {
this.store.load({
workspaceId: this.workspaceId,
page: parseInt(qp.get('page') ?? '1'),
status: qp.get('status') ?? undefined,
priority: qp.get('priority') ?? undefined,
q: qp.get('q') ?? undefined,
sort: qp.get('sort') ?? '-createdAt',
});
});
}
onParamsChange(params: Partial<TaskQueryParams>): void {
this.router.navigate([], {
queryParams: { ...params, page: 1 },
queryParamsHandling:'merge',
});
}
onComplete(taskId: string): void {
this.store.updateOptimistic(taskId, { status: 'done' });
}
onDelete(taskId: string): void {
this.store.deleteOptimistic(taskId);
}
onPageChange(page: number): void {
this.router.navigate([], {
queryParams: { page },
queryParamsHandling:'merge',
});
}
reload(): void { this.store.load(this.store.params()); }
onEdit(taskId: string): void { this.router.navigate(['tasks', taskId, 'edit']); }
onCreateTask(): void { this.router.navigate(['tasks', 'new']); }
ngOnDestroy(): void {
this.store['socketService'].leaveWorkspace(this.workspaceId);
}
}
How It Works
Step 1 — Unidirectional Data Flow Keeps Components Predictable
All task state lives in TaskStore. Components read from store signals (store.tasks(), store.isLoading()) and call store actions (store.createOptimistic(), store.deleteOptimistic()). No component holds a copy of task data in its own state — which would create synchronisation problems. When a Socket.io event updates the store, all components reading from the same signals automatically re-render with the new data.
Step 2 — Real-Time Socket Events Are Idempotent
The TASK_CREATED socket handler checks if the task already exists before adding it — if the optimistic version is already in the list (with the same _id), it updates it rather than adding a duplicate. This handles the race condition where the user creates a task, the optimistic item appears immediately, and then the socket event arrives. Without this check, the task would appear twice.
Step 3 — URL-Driven Filter State Enables Deep Linking
Filter state (status, priority, search query, sort, page) lives in URL query parameters. Changing a filter navigates with queryParamsHandling: 'merge' — only the changed params update, others stay. The queryParamMap subscription loads data whenever the URL changes. This means bookmark a filtered view, share a URL with a colleague, use the browser back button — all work correctly. The URL is the single source of truth for filter state.
Step 4 — DestroyRef Enables Subscription Cleanup in Services
takeUntilDestroyed(this.destroyRef) works in services and components alike — unlike the component-only takeUntilDestroyed() (without inject). The DestroyRef injected in the TaskStore service completes when the service is destroyed. For singleton services (providedIn: 'root'), this is when the app closes — effectively never. For feature-scoped services, it is when the feature module is unloaded. Either way, the pattern is consistent.
Step 5 — OnPush with Signals Gives Zero Unnecessary Re-renders
ChangeDetectionStrategy.OnPush combined with Angular signals means the component only re-renders when a signal it reads changes value. Reading store.tasks() in the template registers the component as a consumer of that signal — when the signal emits a new value (from any source: API response, Socket.io event, optimistic update), Angular marks the component dirty and re-renders on the next change detection cycle. Unrelated state changes do not trigger re-renders.
Quick Reference
| Pattern | Code |
|---|---|
| State machine signal | private _state = signal<'idle'|'loading'|'loaded'|'error'>('idle') |
| Optimistic update | Update signal → API call → replace on success, revert on error |
| Per-item pending | private _pending = signal<Set<string>>(new Set()) |
| Socket cleanup | .pipe(takeUntilDestroyed(this.destroyRef)) |
| Track in @for | @for (task of tasks(); track task._id) |
| URL filter state | router.navigate([], { queryParams, queryParamsHandling: 'merge' }) |
| Read query params | route.queryParamMap.pipe(takeUntilDestroyed()).subscribe(...) |
| OnPush + signals | changeDetection: ChangeDetectionStrategy.OnPush |