Service-Based State Management with Signals

Application state โ€” the data shared across multiple components โ€” needs a home. In small Angular applications, a shared service with BehaviorSubject and Observables is the traditional answer. With Angular Signals, this pattern becomes significantly cleaner: a root-provided service holds writable signals as private state, exposes them as read-only signals for components to read, and provides methods that update the state. This pattern โ€” sometimes called a Signal Store โ€” gives you predictable, reactive state management without the complexity of NgRx or the overhead of external state libraries, perfectly suited to MEAN Stack applications.

Signal Store Pattern

Element Implementation Visibility
Private state Private writable signals Internal to the service
Public state asReadonly() or computed() Read by any component
Derived state Public computed() signals Read-only, auto-updated
Actions Public methods that call API and update signals Called by components
Effects Initialisation in constructor Internal side effects

Comparison: BehaviorSubject vs Signal Store

Aspect BehaviorSubject Pattern Signal Store Pattern
State declaration private _tasks = new BehaviorSubject<Task[]>([]) private _tasks = signal<Task[]>([])
Read state tasks$ = this._tasks.asObservable() tasks = this._tasks.asReadonly()
Update state this._tasks.next([...tasks, newTask]) this._tasks.update(t => [...t, newTask])
Derived state pending$ = this.tasks$.pipe(map(...)) pending = computed(() => this._tasks().filter(...))
Template consumption tasks$ | async โ€” async pipe needed tasks() โ€” call syntax, no pipe
Combine multiple combineLatest([tasks$, user$]) computed(() => ({ tasks: this.tasks(), user: this.user() }))
Note: Angular’s built-in signal store pattern is intentionally simple โ€” it is not a replacement for NgRx for complex applications with many interacting features, time-travel debugging needs, or large teams. For a MEAN Stack task manager application, signal stores in services are the right level of complexity. If your application grows to require strict action tracking, developer tools, or multi-team conventions, NgRx Signals (the NgRx team’s signal-based state management) is a natural progression that builds on the same signal primitives.
Tip: Keep your signal stores focused and feature-scoped. One TaskStore for all task-related state, one AuthStore for authentication state, one UIStore for global UI state (theme, sidebar open/closed, active modal). Each store is a singleton service with clearly named signals and methods. Components inject only the stores they need โ€” a TaskCardComponent only needs TaskStore, not AuthStore, even though the page might use both.
Warning: Avoid creating deeply nested computed signal chains that recompute on every tiny state change. A computed signal chain like A โ†’ B โ†’ C โ†’ D โ†’ E means changing A marks all five as dirty. If five components display different parts of this chain, they all re-render. Design your signal stores so that computations are at the appropriate granularity โ€” broad state at the top (all tasks), targeted computations for specific views (overdue tasks for the dashboard widget).

Complete Signal Store Implementation

// core/stores/task.store.ts
import { Injectable, computed, signal, inject } from '@angular/core';
import { TaskService }    from '../services/task.service';
import { ToastService }   from '../services/toast.service';
import { Task, CreateTaskDto, UpdateTaskDto } from '../../shared/models/task.model';

export type TaskLoadingState = 'idle' | 'loading' | 'loaded' | 'error';

@Injectable({ providedIn: 'root' })
export class TaskStore {
    private taskService = inject(TaskService);
    private toast       = inject(ToastService);

    // โ”€โ”€ Private writable state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    private _tasks       = signal<Task[]>([]);
    private _loadState   = signal<TaskLoadingState>('idle');
    private _error       = signal<string | null>(null);
    private _pendingIds  = signal<Set<string>>(new Set()); // IDs with in-flight requests
    private _selectedIds = signal<Set<string>>(new Set());

    // โ”€โ”€ Public read-only signals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    readonly tasks      = this._tasks.asReadonly();
    readonly loadState  = this._loadState.asReadonly();
    readonly error      = this._error.asReadonly();
    readonly selectedIds= this._selectedIds.asReadonly();

    // โ”€โ”€ Computed / derived state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    readonly isLoading  = computed(() => this._loadState() === 'loading');
    readonly isLoaded   = computed(() => this._loadState() === 'loaded');
    readonly hasError   = computed(() => this._loadState() === 'error');

    readonly pending    = computed(() => this._tasks().filter(t => t.status === 'pending'));
    readonly active     = computed(() => this._tasks().filter(t => t.status === 'in-progress'));
    readonly completed  = computed(() => this._tasks().filter(t => t.status === 'completed'));

    readonly overdue    = computed(() =>
        this._tasks().filter(t =>
            t.dueDate
            && new Date(t.dueDate) < new Date()
            && t.status !== 'completed'
        )
    );

    readonly stats = computed(() => ({
        total:     this._tasks().length,
        pending:   this.pending().length,
        active:    this.active().length,
        completed: this.completed().length,
        overdue:   this.overdue().length,
    }));

    readonly selectedTasks = computed(() =>
        this._tasks().filter(t => this._selectedIds().has(t._id))
    );

    readonly isEmpty = computed(() => this.isLoaded() && this._tasks().length === 0);

    isTaskPending = (id: string) => computed(() => this._pendingIds().has(id));

    // โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    loadAll(): void {
        this._loadState.set('loading');
        this._error.set(null);

        this.taskService.getAll().subscribe({
            next: tasks => {
                this._tasks.set(tasks);
                this._loadState.set('loaded');
            },
            error: err => {
                this._error.set(err.message ?? 'Failed to load tasks');
                this._loadState.set('error');
            },
        });
    }

    create(dto: CreateTaskDto): void {
        this.taskService.create(dto).subscribe({
            next: task => {
                this._tasks.update(tasks => [task, ...tasks]);
                this.toast.success('Task created');
            },
            error: err => this.toast.error('Failed to create task: ' + err.message),
        });
    }

    update(id: string, dto: UpdateTaskDto): void {
        this._setPending(id, true);

        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.toast.error('Update failed: ' + err.message);
                this._setPending(id, false);
            },
        });
    }

    delete(id: string): void {
        this._setPending(id, true);

        this.taskService.delete(id).subscribe({
            next: () => {
                this._tasks.update(tasks => tasks.filter(t => t._id !== id));
                this._selectedIds.update(set => {
                    const next = new Set(set);
                    next.delete(id);
                    return next;
                });
                this._setPending(id, false);
                this.toast.success('Task deleted');
            },
            error: err => {
                this.toast.error('Delete failed: ' + err.message);
                this._setPending(id, false);
            },
        });
    }

    complete(id: string): void {
        this.update(id, { status: 'completed' });
    }

    toggleSelection(id: string): void {
        this._selectedIds.update(set => {
            const next = new Set(set);
            next.has(id) ? next.delete(id) : next.add(id);
            return next;
        });
    }

    clearSelection(): void {
        this._selectedIds.set(new Set());
    }

    // โ”€โ”€ Private helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    private _setPending(id: string, isPending: boolean): void {
        this._pendingIds.update(set => {
            const next = new Set(set);
            isPending ? next.add(id) : next.delete(id);
            return next;
        });
    }
}

// โ”€โ”€ Usage in components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
    selector:   'app-task-list',
    standalone: true,
    template: `
        <app-spinner *ngIf="store.isLoading()"></app-spinner>
        <p *ngIf="store.hasError()">{{ store.error() }}</p>
        <p *ngIf="store.isEmpty()">No tasks yet!</p>
        <ul *ngIf="store.isLoaded() && !store.isEmpty()">
            <li *ngFor="let task of store.tasks(); trackBy: trackById">
                <app-task-card
                    [task]="task"
                    [isDeleting]="store.isTaskPending(task._id)()"
                    [isSelected]="store.selectedIds().has(task._id)"
                    (completed)="store.complete($event)"
                    (deleted)="store.delete($event)"
                    (selected)="store.toggleSelection($event)">
                </app-task-card>
            </li>
        </ul>
    `,
})
export class TaskListComponent implements OnInit {
    store    = inject(TaskStore);
    trackById = (_: number, t: Task): string => t._id;

    ngOnInit(): void { this.store.loadAll(); }
}

How It Works

Step 1 โ€” Private Signals Hide Mutation Capability

By making signals private (private _tasks = signal<Task[]>([])) and exposing them as read-only (readonly tasks = this._tasks.asReadonly()), the store enforces that only its own methods can change state. Components can read the state freely but cannot call tasks.set() or tasks.update(). All mutations go through named action methods like create(), delete(), and update(). This makes state changes traceable โ€” you always know which method changed the state.

Step 2 โ€” Computed Signals Provide a Single Source of Truth for Derived Data

Instead of every component computing its own filtered/sorted/counted view of the task list, computed signals in the store do it once and cache it. The stats computed signal is used by the dashboard, the sidebar badge, and the filter toolbar โ€” all reading the same computation. When tasks change, all three update simultaneously from the same recomputed value. No more inconsistencies between components showing different counts.

Step 3 โ€” Actions Are the Only Path to State Mutation

Methods like create(), delete(), and update() are the store’s action boundary. They make the HTTP call, handle success and error, update the optimistic or confirmed state, and trigger notifications. Components never call the HTTP service directly โ€” they call the store method. This means the complete flow of any state change (API call โ†’ state update โ†’ notification) is documented and auditable in one place.

Step 4 โ€” Optimistic Updates and Pending States Improve UX

The _pendingIds signal tracks which task IDs have in-flight requests. Components can check store.isTaskPending(task._id)() to show a loading spinner or disable a button for that specific item. This is a computed signal factory pattern โ€” isTaskPending(id) returns a computed signal. The component receives a reactive value that automatically updates when the pending state changes without needing to subscribe to anything.

Step 5 โ€” Multiple Components Share State Reactively

Because the store is provided in root, every component that injects TaskStore gets the same instance with the same signals. When the task list page calls store.delete(id) and the store updates _tasks, a sidebar widget displaying store.stats().pending automatically updates because its stats computed signal depends on _tasks. No manual event broadcasting, no shared Observable subscriptions โ€” the signal dependency graph handles all coordination.

Common Mistakes

Mistake 1 โ€” Making signals public and writeable (any component can mutate state)

โŒ Wrong โ€” components directly modify shared state:

@Injectable({ providedIn: 'root' })
export class TaskStore {
    tasks = signal<Task[]>([]);  // public writable โ€” any component can tasks.set([])!
}

✅ Correct โ€” private state, public read-only access, named actions:

@Injectable({ providedIn: 'root' })
export class TaskStore {
    private _tasks = signal<Task[]>([]);
    readonly tasks = this._tasks.asReadonly();   // read-only public

    delete(id: string): void { /* only method that removes tasks */ }
}

Mistake 2 โ€” Loading data in every component that needs it (duplicating API calls)

โŒ Wrong โ€” three components each call the API independently:

// Each component loads its own copy
export class TaskListComponent { ngOnInit() { this.taskService.getAll().subscribe(...) } }
export class SidebarComponent  { ngOnInit() { this.taskService.getAll().subscribe(...) } }
export class DashboardWidget   { ngOnInit() { this.taskService.getAll().subscribe(...) } }

✅ Correct โ€” load once in the store, all components read from shared signals:

// Root page component calls store.loadAll() once
// All child components inject TaskStore and read store.tasks()

Mistake 3 โ€” Calling loadAll() in every component’s ngOnInit

โŒ Wrong โ€” API called every time any task-related component mounts:

// Every component triggers a reload
export class TaskCardComponent {
    ngOnInit() { this.store.loadAll(); }  // called for every card!
}

✅ Correct โ€” load once at the page/route level, never in leaf components:

// Only the TaskListPage (smart container) loads:
export class TaskListPage {
    ngOnInit() { this.store.loadAll(); }
}
// TaskCard, TaskFilter etc. just read store.tasks() โ€” never trigger loads

Quick Reference

Pattern Code
Private state signal private _tasks = signal<Task[]>([])
Expose read-only readonly tasks = this._tasks.asReadonly()
Derived state readonly pending = computed(() => this._tasks().filter(...))
Update on success this._tasks.update(list => [...list, newItem])
Replace on reload this._tasks.set(freshList)
Remove one item this._tasks.update(list => list.filter(t => t._id !== id))
Update one item this._tasks.update(list => list.map(t => t._id === id ? updated : t))
Loading flag private _loading = signal(false); readonly isLoading = this._loading.asReadonly()

🧠 Test Yourself

A TaskStore has private _tasks = signal<Task[]>([]) and a delete(id: string) method. After the API call succeeds, what is the correct way to update the signal?