Introduction to Signals
Signals are Angular’s reactive primitive: a wrapper around a value that knows when it is read and notifies anything that depends on it when the value changes. Introduced as stable in Angular 16+ and expanded heavily through 17, 18, and 19, signals give the framework fine-grained reactivity, meaning Angular can update exactly the parts of the DOM that depend on a changed value instead of re-checking an entire component tree. If you have ever wrestled with ChangeDetectionStrategy, manual markForCheck() calls, or runaway re-renders, signals are the modern answer.
What is a signal?
A signal is a function that returns the current value when you call it. Calling the signal (count()) reads the value and, when done inside a reactive context such as a template or a computed, registers a dependency. Updating the signal (count.set(5)) changes the value and schedules every dependent computation and view for an update.
import { signal } from '@angular/core';
const count = signal(0);
console.log(count()); // read the current value
count.set(5); // replace the value
count.update(n => n + 1); // derive the next value from the current one
console.log(count());
Output:
0
6
Because reading a signal is just a function call, there is no special syntax to subscribe and nothing to unsubscribe from. The dependency graph is tracked automatically and torn down when a consumer is destroyed.
Why signals matter
Traditional Angular relied on Zone.js to detect that something changed and then dirty-checked components. Signals flip this around: a value declares precisely who depends on it, so updates are targeted and predictable. This unlocks zoneless change detection (provideZonelessChangeDetection()), better performance on large views, and a mental model that is easy to reason about.
| Aspect | Zone.js / default CD | Signals |
|---|---|---|
| Update granularity | Whole component tree | Only dependent expressions |
| Subscription | Implicit, via zones | Tracked automatically on read |
| Boilerplate | markForCheck, async pipe | Read the signal directly |
| Zoneless support | No | Yes |
| Debuggability | Hard to trace | Explicit dependency graph |
Using a signal in a component
Signals shine inside standalone components. Read the signal directly in the template, and Angular keeps that binding in sync without an async pipe or manual change detection.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
@if (count() > 0) {
<p>Positive!</p>
}
<button (click)="increment()">Add</button>
<button (click)="reset()">Reset</button>
`,
})
export class CounterComponent {
protected readonly count = signal(0);
increment(): void {
this.count.update(n => n + 1);
}
reset(): void {
this.count.set(0);
}
}
Notice the new @if control flow reading count() directly. Each time count changes, only the text interpolation and the @if block are re-evaluated, not unrelated parts of the page.
Reading vs. writing
A WritableSignal (the return type of signal()) exposes three mutating methods. Read-only signals (such as those returned by computed() or input()) expose none of them.
| Operation | Method | Use when |
|---|---|---|
| Read | value() | You need the current value (registers a dependency in reactive contexts) |
| Set | value.set(x) | You have the next value outright |
| Update | value.update(fn) | The next value derives from the current one |
| Read-only view | value.asReadonly() | You want to expose a signal without write access |
Tip: Treat signal values as immutable. Always produce a new object or array with
set/updaterather than mutating in place — Angular compares by reference, so an in-place mutation will not notify consumers.
const todos = signal<string[]>([]);
// Correct: new array reference
todos.update(list => [...list, 'Write docs']);
// Wrong: mutating in place does NOT trigger updates
// todos().push('Broken');
Equality checks
By default signals use Object.is to decide whether a value actually changed; setting a signal to a value equal to the current one is a no-op and skips notifications. You can supply a custom equal function for value objects.
import { signal } from '@angular/core';
const point = signal(
{ x: 0, y: 0 },
{ equal: (a, b) => a.x === b.x && a.y === b.y }
);
point.set({ x: 0, y: 0 }); // no change detected, consumers not notified
Best practices
- Read signals only where you need them; reading inside a
computedor template is what builds the dependency graph. - Keep signal values immutable — return new objects/arrays from
updateinstead of mutating. - Expose write-protected state with
asReadonly()so consumers cannot bypass your component’s API. - Prefer
update(fn)oversetwhen the next value depends on the current one to avoid stale reads. - Derive state with
computed()rather than duplicating it in multiple writable signals. - Adopt zoneless change detection on new apps to get the full performance benefit of fine-grained reactivity.