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
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
@Injectableservice, 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
| Signals | Observables (RxJS) | |
|---|---|---|
| Always has a value | Yes | No (need BehaviorSubject) |
| Synchronous reads | Yes | Can be async |
| Auto-cleanup | Yes | Manual unsubscribe() |
| Template syntax | {{ count() }} | {{ count$ | async }} |
| Operators | None | Full RxJS operator set |
| Designed for | Synchronous state | Async event streams |
Common mistakes
1. Forgetting () on reads
{{ count }} // shows the Signal object, no reactivity
{{ count() }} // correct: registers as a dependency2. Reading a Signal in the constructor
constructor() {
console.log(this.count()); // executes once, never updates
}
// Fix: move reads into effect() or a lifecycle hook3. Using set with a manual read instead of update
count.set(count() + 1); // reads twice, risks a stale value in concurrent code
count.update(c => c + 1); // correct: single atomic transform4. Mutating Signal arrays directly
items.value.push('new'); // no notification, array changed silently
items.update(arr => [...arr, 'new']); // correct: new reference notifies dependents5. Stale closures in effects
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
mapchains - 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
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
// 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() }} doneAny 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+)
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 readyA concise answer to help you respond confidently on this topic during an interview.