Angular Signals, introduced in Angular 16 and stabilised in Angular 17, are a new reactive primitive that replaces much of the need for RxJS in component state management. A signal is a wrapper around a value that notifies interested consumers whenever the value changes — Angular’s change detection being the primary consumer. Unlike RxJS Observables (which are lazy streams), signals are always synchronous, always hold a current value, and have zero subscription boilerplate. Understanding signals — and knowing when to use them versus RxJS — is the most important Angular skill for new projects as of 2024+.
Signal Primitives
| Function | Creates | Description |
|---|---|---|
signal(initialValue) |
Writable signal | Holds a mutable value — updated with .set(), .update(), .mutate() |
computed(() => expr) |
Read-only computed signal | Derives value from other signals — auto-updates when dependencies change |
effect(() => sideEffect) |
Reactive side effect | Runs whenever any signal it reads changes — for non-signal side effects |
toSignal(observable$) |
Read-only signal | Converts an Observable to a Signal — bridges RxJS and Signals |
toObservable(signal) |
Observable | Converts a Signal to an Observable — bridges Signals and RxJS |
signal.asReadonly() |
Read-only signal | Exposes a writable signal as read-only for public APIs |
Writable Signal Methods
| Method | Use For | Example |
|---|---|---|
.set(newValue) |
Replace the value entirely | count.set(42) |
.update(fn) |
Derive new value from current value | count.update(n => n + 1) |
.update(fn) for arrays |
Return new array — signals track by reference | items.update(list => [...list, newItem]) |
Signals vs RxJS — When to Use Each
| Use Signals When | Use RxJS When |
|---|---|
| Managing component UI state | HTTP requests (HttpClient returns Observables) |
| Derived computed values | Complex async orchestration (switchMap, combineLatest, debounceTime) |
| Replacing simple BehaviorSubjects in services | WebSocket streams and event streams |
| Communicating between parent/child components | Time-based operations (interval, timer, debounce) |
| Simple on/off flags, counters, form state | Combining multiple async sources |
computed() is lazy — it only re-evaluates when one of its signal dependencies changes AND the computed value is actually read. If nothing reads a computed signal, it does not recompute even when dependencies change. This is important for performance: a component with 10 computed signals that are not in the current view does not waste computation keeping them up-to-date. Angular tracks signal dependencies automatically — there is no dependency array to maintain like in React hooks.toSignal() to bring Observable data into the signals world. tasks = toSignal(this.taskService.getAll(), { initialValue: [] }) creates a signal that starts with an empty array and updates whenever the Observable emits. The signal can then be used in computed() and templates without any subscription management. The signal automatically unsubscribes when the injection context is destroyed — no takeUntilDestroyed() needed.effect() should be used sparingly and only for genuine side effects — things that cannot be expressed as computed() or handled by Angular’s template binding. Using effect() to synchronise one signal’s value based on another (instead of using computed()) is an antipattern that creates circular dependencies and debugging nightmares. If you find yourself writing effect(() => { this.b.set(transform(this.a())) }), use b = computed(() => transform(this.a())) instead.Complete Signal Examples
import {
Component, OnInit, signal, computed, effect, inject,
} from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { TaskService } from '../../../core/services/task.service';
import { Task } from '../../../shared/models/task.model';
@Component({ selector: 'app-task-dashboard', standalone: true, ... })
export class TaskDashboardComponent implements OnInit {
private taskService = inject(TaskService);
// ── Writable signals — mutable state ─────────────────────────────────
tasks = signal<Task[]>([]);
filterStatus = signal<string>('');
searchQuery = signal('');
selectedIds = signal<Set<string>>(new Set());
loading = signal(true);
sortField = signal<'priority' | 'dueDate' | 'title'>('priority');
sortDir = signal<'asc' | 'desc'>('desc');
// ── Computed signals — derived state ──────────────────────────────────
// Filtered by status
filteredByStatus = computed(() => {
const status = this.filterStatus();
return status
? this.tasks().filter(t => t.status === status)
: this.tasks();
});
// Then filtered by search query
filteredTasks = computed(() => {
const q = this.searchQuery().toLowerCase();
return q
? this.filteredByStatus().filter(t =>
t.title.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q)
)
: this.filteredByStatus();
});
// Then sorted
sortedTasks = computed(() => {
const field = this.sortField();
const dir = this.sortDir();
return [...this.filteredTasks()].sort((a, b) => {
const av = a[field] ?? '';
const bv = b[field] ?? '';
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return dir === 'asc' ? cmp : -cmp;
});
});
// Statistics
stats = computed(() => ({
total: this.tasks().length,
filtered: this.filteredTasks().length,
selected: this.selectedIds().size,
pending: this.tasks().filter(t => t.status === 'pending').length,
completed: this.tasks().filter(t => t.status === 'completed').length,
overdue: this.tasks().filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'completed'
).length,
}));
// All items selected?
allSelected = computed(() =>
this.filteredTasks().length > 0
&& this.filteredTasks().every(t => this.selectedIds().has(t._id))
);
// ── toSignal — bridge Observable to Signal ────────────────────────────
// HTTP call converted to signal — no subscription needed
taskStats = toSignal(
this.taskService.getStats(), // Observable from HTTP
{ initialValue: { pending: 0, completed: 0, overdue: 0 } }
);
// ── Search with debounce using toObservable + toSignal ────────────────
// Convert signal to Observable, debounce, search, convert back
searchResults = toSignal(
toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => q.length >= 2
? this.taskService.search(q)
: []
)
),
{ initialValue: [] as Task[] }
);
// ── effect — genuine side effects only ────────────────────────────────
constructor() {
// Log analytics when filter changes (side effect — not transforming data)
effect(() => {
const status = this.filterStatus();
if (status) {
// analytics.track('filter_applied', { status });
console.log('Filter applied:', status);
}
});
// Persist sort preferences to localStorage (side effect)
effect(() => {
localStorage.setItem('taskSort', JSON.stringify({
field: this.sortField(),
dir: this.sortDir(),
}));
});
}
ngOnInit(): void {
// Restore persisted sort
const saved = localStorage.getItem('taskSort');
if (saved) {
const { field, dir } = JSON.parse(saved);
this.sortField.set(field);
this.sortDir.set(dir);
}
// Load tasks
this.taskService.getAll().subscribe({
next: tasks => { this.tasks.set(tasks); this.loading.set(false); },
error: () => this.loading.set(false),
});
}
// ── State mutation methods ────────────────────────────────────────────
toggleSort(field: typeof this.sortField extends (() => infer T) ? T : never): void {
if (this.sortField() === field) {
this.sortDir.update(d => d === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field as any);
this.sortDir.set('asc');
}
}
toggleSelect(id: string): void {
this.selectedIds.update(set => {
const next = new Set(set);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
selectAll(): void {
if (this.allSelected()) {
this.selectedIds.set(new Set());
} else {
this.selectedIds.set(new Set(this.filteredTasks().map(t => t._id)));
}
}
removeTask(id: string): void {
this.tasks.update(tasks => tasks.filter(t => t._id !== id));
}
}
How It Works
Step 1 — Reading a Signal Registers a Dependency
When you call this.tasks() inside a computed() or effect(), Angular registers tasks as a dependency of that computation. The next time tasks changes (via .set() or .update()), Angular marks the dependent computed signals as dirty and schedules a re-check. In templates, reading a signal with {{ tasks() }} registers the template as a consumer — when the signal changes, only that component’s template is updated, not the entire tree.
Step 2 — computed() Is Memoized and Lazy
A computed signal stores its last calculated value. When a dependency changes, the computed signal is marked “dirty” — but it does not immediately recompute. The recomputation happens the next time the computed value is read (either by a template, another computed signal, or an effect). If a dirty computed signal is never read before the next change, it recomputes only once for the final state — skipping intermediate states. This lazy evaluation makes deeply chained computed signals efficient.
Step 3 — effect() Runs Synchronously After Change Detection
Effects run after Angular’s change detection cycle completes. They are not synchronous with the signal change — they batch and run after all changes are applied. Effects automatically track their signal dependencies on first run. Do not call signal.set() inside an effect that reads the same signal — this creates an infinite loop. Use effects only for external side effects: logging, analytics, localStorage, third-party DOM libraries.
Step 4 — toSignal() Subscribes and Manages Lifetime
toSignal(observable$) subscribes to the Observable and stores the latest emitted value in a signal. It must be called in an injection context because it injects DestroyRef to automatically unsubscribe when the component or service is destroyed. The initialValue option sets the signal’s value before the Observable emits — without it, the signal is undefined initially and typed as T | undefined.
Step 5 — Signals Integrate Natively with Angular Templates
Angular templates treat signal functions as reactive expressions. {{ tasks() }} in a template subscribes to the signal and re-renders the interpolation when the signal changes. *ngFor="let t of tasks()" re-runs when the array changes. [disabled]="loading()" updates the DOM attribute reactively. No async pipe, no changeDetectorRef.markForCheck(), no zone notifications — the signal system handles all change detection coordination automatically.
Common Mistakes
Mistake 1 — Using effect() to synchronise signals (use computed() instead)
❌ Wrong — effect for derived state creates a second write cycle:
tasks = signal<Task[]>([]);
filter = signal('');
// BAD: effect to keep filteredTasks in sync
effect(() => {
this.filteredTasks.set(this.tasks().filter(t => t.status === this.filter()));
});
✅ Correct — computed() for derived state — no effect needed:
tasks = signal<Task[]>([]);
filter = signal('');
filteredTasks = computed(() => // auto-updates — no effect needed
this.tasks().filter(t => t.status === this.filter())
);
Mistake 2 — Mutating array/object signals without returning new references
❌ Wrong — push() mutates in place, Angular does not detect the change:
tasks = signal<Task[]>([]);
addTask(task: Task): void {
this.tasks().push(task); // mutates the existing array — signal not notified!
// Template does not update
}
✅ Correct — always return a new array reference:
addTask(task: Task): void {
this.tasks.update(tasks => [...tasks, task]); // new array — signal notified
}
Mistake 3 — Calling toSignal() outside an injection context
❌ Wrong — toSignal() called after construction — no DestroyRef available:
export class MyComponent implements OnInit {
tasks!: Signal<Task[]>;
ngOnInit(): void {
this.tasks = toSignal(this.taskService.getAll()); // Error: no injection context
}
}
✅ Correct — call toSignal() at field initialisation or in constructor:
export class MyComponent {
tasks = toSignal(inject(TaskService).getAll(), { initialValue: [] });
// OR in constructor:
// constructor() { this.tasks = toSignal(...); }
}
Quick Reference
| Task | Code |
|---|---|
| Create signal | count = signal(0) |
| Read signal | this.count() or {{ count() }} in template |
| Set value | this.count.set(42) |
| Update from current | this.count.update(n => n + 1) |
| Derived value | doubled = computed(() => this.count() * 2) |
| Side effect | effect(() => { console.log(this.count()) }) |
| Observable → Signal | data = toSignal(obs$, { initialValue: [] }) |
| Signal → Observable | obs$ = toObservable(this.mySignal) |
| Read-only exposure | readonly count = this._count.asReadonly() |
| Array update | items.update(list => [...list, newItem]) |