Angular Task List — TaskStore Signals, Real-Time Socket Integration, and URL Filters

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
Note: The 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.
Tip: Use 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.
Warning: @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

🧠 Test Yourself

A user creates a task (optimistic item appears instantly) and at the same moment a Socket.io task:created event arrives for the same task. Without the idempotency check, what would happen and how does the check fix it?