Angular Signals — signal(), computed(), effect(), and toSignal()

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
Note: 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.
Tip: Use 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.
Warning: 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])

🧠 Test Yourself

A component has tasks = signal<Task[]>([]) and filterStatus = signal(''). The filtered list needs to update whenever either signal changes. What is the correct implementation?