Skip to main content

Signals in Angular

Angular Signals are reactive state containers introduced in Angular 16. They track dependencies automatically and update only the parts of the UI that actually read the changed value.

Theory

TL;DR

  • Think of Signals like a spreadsheet cell: change A1 and only the cells that reference A1 recalculate, not the whole sheet
  • Zone.js scanned the entire component tree after every event; Signals push updates only through a recorded dependency graph
  • Three primitives: signal() holds state, computed() derives values, effect() runs side effects
  • New app on Angular 16+: prefer Signals for local and shared state; keep RxJS for HTTP, WebSocket, and timer streams
  • Bridge between the two: toSignal() wraps an Observable, toObservable() does the reverse

Quick example

typescript
import { Component, signal, computed } from '@angular/core'; @Component({ template: ` <p>Count: {{ count() }}</p> <p>Double: {{ double() }}</p> <button (click)="increment()">+</button> ` }) export class CounterComponent { count = signal(0); // writable signal double = computed(() => this.count() * 2); // recalculates only when count changes increment() { this.count.update(v => v + 1); // atomic update, not count.set(count() + 1) } }

Click "+" once: count becomes 1, double becomes 2. Only those two bindings re-render. Nothing else in the component tree is touched.

Key difference from Zone.js

Zone.js monkey-patches browser APIs and triggers a full component tree scan after every event, timeout, or HTTP response. Signals build a dependency graph at read time: when a template or computed() calls count(), Angular records that connection. On set() or update(), the scheduler traverses only the recorded edges and queues a microtask via queueMicrotask. No Zone.js needed. Angular benchmarks show 2-10x speed improvements on large component trees when running Signal-based apps in Zone-less mode.

When to use

  • Local component state (form field, toggle, counter) → signal(), minimal setup
  • Shared app-wide state → Signal in an @Injectable service, injected where needed
  • Values derived from other state → computed(), cached and lazy
  • HTTP requests, WebSocket streams, timers → RxJS, then bridge with toSignal() for templates
  • Existing Zone.js codebase → Signals work alongside; migrate incrementally

How it works internally

signal(0) creates a node in a reactive graph. Calling count() inside computed() or a template binding records an edge from that reader to the signal node. On count.set(1), the scheduler marks all dependent nodes dirty and queues a microtask. During the flush, Angular re-evaluates only the dirty nodes. computed() values are cached: if nothing upstream changed since the last read, the cached value is returned immediately without re-running the function.

effect() follows the same pattern. It re-runs when any signal it read during the last execution changes. Before the next run, Angular calls the onCleanup() callback registered in the previous run, so you can cancel timers or abort fetch requests cleanly.

In practice, teams often spend hours debugging Zone.js detection issues that disappear entirely once that piece of state moves into a Signal.

Signals vs Observables

SignalsObservables (RxJS)
Always has a valueYesNo (need BehaviorSubject)
Synchronous readsYesCan be async
Auto-cleanupYesManual unsubscribe()
Template syntax{{ count() }}{{ count$ | async }}
OperatorsNoneFull RxJS operator set
Designed forSynchronous stateAsync event streams

Common mistakes

1. Forgetting () on reads

typescript
{{ count }} // shows the Signal object, no reactivity {{ count() }} // correct: registers as a dependency

2. Reading a Signal in the constructor

typescript
constructor() { console.log(this.count()); // executes once, never updates } // Fix: move reads into effect() or a lifecycle hook

3. Using set with a manual read instead of update

typescript
count.set(count() + 1); // reads twice, risks a stale value in concurrent code count.update(c => c + 1); // correct: single atomic transform

4. Mutating Signal arrays directly

typescript
items.value.push('new'); // no notification, array changed silently items.update(arr => [...arr, 'new']); // correct: new reference notifies dependents

5. Stale closures in effects

typescript
externalValue = 10; // Wrong: externalValue is captured once at creation, never re-tracked effect(() => console.log(this.count() * externalValue)); // Right: read the class property inside the effect body effect(() => console.log(this.count() * this.externalValue));

Real-world usage

  • NgRx 17+ adopted Signals for store selectors, replacing RxJS map chains
  • Angular Material 16+ data tables accept a Signal array as dataSource
  • Angular 17 introduced input.required<string>() as a Signal-based replacement for @Input()
  • PrimeNG data tables use toSignal() to bridge HTTP Observables into templates
  • Nx workspaces use Signal-based services for cross-module state in micro-frontends

Follow-up questions

Q: What is the difference between a writable signal and a computed signal?
A: A writable signal (signal(0)) exposes set() and update() methods to change its value. A computed() is read-only and derives its value from other signals automatically; calling set() on it throws an error.

Q: How do Signals improve performance over Zone.js?
A: Zone.js triggers a full tree scan after every async event. Signals update only dirty nodes in the dependency graph. Angular benchmarks record 2-10x speed gains on large apps in Zone-less mode.

Q: When does a computed() recalculate?
A: Only when at least one upstream signal has changed AND the computed value is actually read. If nothing reads it, recalculation is deferred. The result is cached between reads.

Q: What does effect() do between runs?
A: Before each re-run, Angular calls the onCleanup() callback from the previous run. Use it to cancel timers, abort requests, or remove event listeners.

Q: In Angular 18+ Zone-less mode, how do Signals prevent UI tearing during batched updates?
A: The scheduler batches multiple set() calls into one microtask flush. All computed() reads during that flush see a consistent snapshot of all signal values, so partial states never reach the template.

Examples

Basic counter with derived state

typescript
import { Component, signal, computed } from '@angular/core'; @Component({ selector: 'app-counter', template: ` <p>Count: {{ count() }}</p> <p>Status: {{ status() }}</p> <button (click)="increment()">+</button> <button (click)="reset()">Reset</button> ` }) export class CounterComponent { count = signal(0); status = computed(() => this.count() % 2 === 0 ? 'even' : 'odd'); increment() { this.count.update(c => c + 1); } reset() { this.count.set(0); } }

status re-evaluates only when count changes. Not on every event in the app, only on the exact dependency.

Shared state in a service

typescript
// todo.service.ts import { Injectable, signal, computed } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class TodoService { private todos = signal<string[]>([]); count = computed(() => this.todos().length); completed = computed(() => this.todos().filter(t => t.startsWith('[x]')).length); addTodo(text: string) { this.todos.update(list => [...list, text]); } removeTodo(index: number) { this.todos.update(list => list.filter((_, i) => i !== index)); } } // In any component: // service = inject(TodoService); // template: {{ service.count() }} items, {{ service.completed() }} done

Any component that reads service.count() updates automatically when addTodo or removeTodo is called. No Subject, no async pipe, no manual unsubscribe.

Input signals with computed validation (Angular 17+)

typescript
import { Component, input, computed } from '@angular/core'; @Component({ selector: 'app-user-card', template: ` <p>{{ name() }}</p> <span *ngIf="isAdult()">18+</span> <p>{{ roleLabel() }}</p> ` }) export class UserCardComponent { name = input.required<string>(); age = input(0); role = input<'admin' | 'user'>('user'); isAdult = computed(() => this.age() >= 18); roleLabel = computed(() => this.role() === 'admin' ? 'Administrator' : 'Member'); }

Parent template: <app-user-card [name]="'Alice'" [age]="25" [role]="'admin'" />. All three inputs are Signals under the hood, so computed() tracks them with no extra wiring and no ngOnChanges.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?