Angular Signals — Complete Guide
1. What are Signals?
A Signal is a wrapper around a value that notifies all consumers automatically whenever that value changes. Think of it as a reactive variable — when it updates, every part of the UI or logic that depends on it re-evaluates instantly.
Starting with Angular 16 (and made the default reactive primitive in Angular 21), Signals power Angular's new zoneless change detection. Instead of Zone.js patching every async operation and triggering full-tree checks, Angular now knows exactly which components need to re-render because Signals track their own dependencies.
Key characteristics of Signals:
- Synchronous — reads and writes happen immediately, no async overhead.
- Explicit — Angular knows precisely which signals a template or computed value depends on.
- Fine-grained — only the components that actually read a changed signal are updated.
- Zero extra libraries — built directly into
@angular/core.
2. Why Signals? — The Problem They Solve
Before Signals, Angular relied on Zone.js for change detection. While Zone.js worked, it came with real costs:
| Zone.js Problem | Impact |
|---|---|
| Ran change detection on the entire component tree | Slow in large apps |
| ~100 KB bundle overhead | Larger initial load |
| Monkey-patched browser APIs | Hard to debug, unexpected behaviour |
| Required RxJS BehaviorSubject for simple state | Boilerplate-heavy |
| async pipe needed in every template | Verbose templates |
Signals solve all of these. They are explicit (Angular tracks exactly what changed), synchronous (no async pipe needed), and require zero extra libraries beyond Angular itself.
3. signal() — Creating a Writable Signal
signal(initialValue) creates a writable signal. You read it by calling it like a function — count(). You write to it with .set() for a direct value or .update() for a function that receives the current value.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(v => v + 1);
}
reset() {
this.count.set(0);
}
}
The three operations you need to know:
| Operation | Syntax | When to use |
|---|---|---|
| Read | count() | In templates or computed/effect bodies |
| Set | count.set(5) | Replace with a known value |
| Update | count.update(v => v + 1) | Derive next value from current |
4. computed() — Derived Read-Only Signals
computed() creates a read-only signal whose value is derived from one or more other signals. It is lazy — it only recalculates when one of its signal dependencies actually changes, and it caches the result in between. You cannot call .set() or .update() on a computed signal.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart',
standalone: true,
template: `
<p>Items: {{ itemCount() }}</p>
<p>Price: ${{ price() }}</p>
<p><b>Total: ${{ total() }}</b></p>
<button (click)="addItem()">Add Item</button>
`
})
export class CartComponent {
itemCount = signal(1);
price = signal(9.99);
total = computed(() => this.itemCount() * this.price());
addItem() {
this.itemCount.update(n => n + 1);
}
}
Every time itemCount changes (via the button), total automatically recalculates. The template re-renders only the parts that read total() — nothing else.
5. effect() — Running Side Effects
effect() registers a callback that runs whenever any signal it reads changes. It is ideal for synchronising state to the outside world — writing to localStorage, updating the DOM directly, logging analytics, or syncing to a third-party library.
Important rule: do not modify signals inside an effect. Doing so can create infinite loops. Effects are for reading and reacting, not for writing back.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-theme',
standalone: true,
template: `
<button (click)="toggleTheme()">
Theme: {{ theme() }}
</button>
`
})
export class ThemeComponent {
theme = signal('light');
constructor() {
effect(() => {
localStorage.setItem('theme', this.theme());
document.body.setAttribute('data-theme', this.theme());
});
}
toggleTheme() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}
The effect runs once immediately on creation, then again every time theme() changes. Angular automatically cleans up the effect when the component is destroyed.
6. Signals in Services — Shared State
The most powerful pattern is placing signals inside an Injectable service. The service becomes the single source of truth for a piece of state. Any component that injects the service can read the signals directly in its template — no BehaviorSubject, no async pipe, no subscriptions to manage.
Expose the internal signal as .asReadonly() so consumers cannot accidentally mutate it. Only the service's own methods can change the state.
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
readonly doubled = computed(() => this._count() * 2);
readonly isZero = computed(() => this._count() === 0);
increment() { this._count.update(v => v + 1); }
decrement() { this._count.update(v => v - 1); }
reset() { this._count.set(0); }
}
import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>Count: {{ svc.count() }}</h2>
<p>Doubled: {{ svc.doubled() }}</p>
<button (click)="svc.increment()">+</button>
<button (click)="svc.decrement()">-</button>
<button (click)="svc.reset()">Reset</button>
`
})
export class CounterComponent {
svc = inject(CounterService);
}
Multiple components can inject CounterService and they all share the same signal state. When one component calls svc.increment(), every other component reading svc.count() updates automatically.
7. input() — Signal-based Component Input
input() is the modern replacement for @Input(). The value is a read-only signal, so you can use it directly inside computed() and effect() without any extra wiring. Use input.required<T>() to mark an input as mandatory — Angular will throw a compile-time error if the parent does not provide it.
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: `<h2>{{ message() }}</h2>`
})
export class GreetingComponent {
name = input.required<string>();
prefix = input('Hello');
message = computed(() => `${this.prefix()}, ${this.name()}!`);
}
// Usage: <app-greeting name="Angular" prefix="Welcome to" />
Because name and prefix are signals, message recomputes automatically whenever the parent changes either binding — no ngOnChanges needed.
8. output() — Signal-based Output
output() replaces @Output() + EventEmitter with a simpler, more explicit API. You call .emit(value) to fire the event, and the parent listens with the same (eventName)="handler($event)" syntax it always used.
import { Component, output } from '@angular/core';
@Component({
selector: 'app-like-button',
standalone: true,
template: `<button (click)="like()">Like ({{ likeCount }})</button>`
})
export class LikeButtonComponent {
likeCount = 0;
liked = output<number>();
like() {
this.likeCount++;
this.liked.emit(this.likeCount);
}
}
// Usage: <app-like-button (liked)="onLiked($event)" />
9. model() — Two-Way Binding Signal
model() combines input() and output() into a single two-way bindable signal. It is the signal-based equivalent of [(ngModel)] for custom components. The parent binds with [(propertyName)] and the child can both read and write the value.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-toggle',
standalone: true,
template: `<button (click)="toggle()">{{ checked() ? 'ON' : 'OFF' }}</button>`
})
export class ToggleComponent {
checked = model(false);
toggle() {
this.checked.update(v => !v);
}
}
// Usage: <app-toggle [(checked)]="isActive" />
When the child calls this.checked.update(), Angular automatically emits a checkedChange event, which the two-way binding syntax [()] uses to update the parent's variable.
10. toSignal() — Converting Observables to Signals
toSignal() from @angular/core/rxjs-interop bridges the RxJS world and the Signals world. It wraps an Observable and returns a Signal — no subscribe(), no async pipe, no manual unsubscribe(). Angular manages the subscription lifecycle automatically.
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (users()) {
@for (user of users()!; track user.id) {
<div>{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`
})
export class UsersComponent {
private http = inject(HttpClient);
users = toSignal(
this.http.get('https://jsonplaceholder.typicode.com/users')
);
}
The signal starts as undefined until the HTTP response arrives, which is why the template checks @if (users()) before iterating. You can also pass { initialValue: [] } as a second argument to avoid the undefined state.
11. Signals vs RxJS — When to Use Each
Signals and RxJS are complementary, not competing. Use the right tool for the job:
| Scenario | Signals | RxJS |
|---|---|---|
| Component state (counter, toggle, form field) | ✓ | |
| Derived / computed values | ✓ | |
| HTTP requests | via toSignal() | ✓ |
| WebSocket / real-time streams | ✓ | |
| Complex async pipelines (debounce, retry, switchMap) | ✓ | |
| Shared app state (user session, cart, theme) | ✓ | |
| Template binding without async pipe | ✓ |
A practical rule of thumb: start with Signals for all synchronous state. Reach for RxJS when you need operators like debounceTime, switchMap, or retry, then bridge back to Signals with toSignal() for the template.
12. Quick Reference — Signals API
| API | Import | Description |
|---|---|---|
signal(value) | @angular/core | Creates a writable signal |
computed(() => ...) | @angular/core | Derived read-only signal, lazy & cached |
effect(() => ...) | @angular/core | Side effect that re-runs on signal change |
input() | @angular/core | Signal-based @Input replacement |
input.required() | @angular/core | Required signal input (compile-time enforced) |
output() | @angular/core | Signal-based @Output replacement |
model() | @angular/core | Two-way bindable signal (input + output) |
viewChild() | @angular/core | Signal-based @ViewChild |
contentChild() | @angular/core | Signal-based @ContentChild |
toSignal(obs$) | @angular/core/rxjs-interop | Converts an Observable to a Signal |
toObservable(sig) | @angular/core/rxjs-interop | Converts a Signal to an Observable |
-
Signals are Angular's reactive primitive —
signal()creates a reactive state container. -
computed()creates a derived signal that automatically updates when its dependencies change. -
effect()runs a side effect whenever any signal it reads changes. - Signals replace the need for RxJS in many cases — they are simpler and more performant.
-
Use
input()andoutput()for signal-based component communication (Angular 17+). -
model()creates a two-way bindable signal — replaces[(ngModel)]in standalone components. - Signals enable zoneless change detection — the future of Angular performance.
Frequently Asked Questions
Ready to Level Up Your Skills?
Explore 500+ free tutorials across 20+ languages and frameworks.