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() })) |
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.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() |