Docs
/
Angular
Chapter 11
11 — Signals & Reactivity
What are Signals?
Signals (Angular 16+) are a synchronous reactive primitive that notify consumers when their value changes. They are Angular's answer to fine-grained reactivity (like Vue's ref or Solid's signals).
signal(value) → computed(fn) → effect(fn)
(writable) (derived) (side effect)
Core API
signal() — Writable Signal
import { signal } from '@angular/core';
const count = signal(0); // Create with initial value
// Read
console.log(count()); // 0
// Write
count.set(5); // Set new value
count.update(c => c + 1); // Functional update (prev → next)
// Read-only view
const readOnly = count.asReadonly();
// readOnly.set(5) ← ❌ Error — cannot write
computed() — Derived Signal
import { computed } from '@angular/core';
const price = signal(100);
const quantity = signal(3);
const tax = signal(0.1);
// Automatically recalculates when ANY dependency changes
const subtotal = computed(() => price() * quantity());
const total = computed(() => subtotal() * (1 + tax()));
console.log(total()); // 330
price.set(200);
console.log(total()); // 660 — auto-updated
> computed is lazy — it only recalculates when read AND a dependency has changed.
effect() — Side Effects
import { effect } from '@angular/core';
const user = signal<User | null>(null);
// Runs when user signal changes
effect(() => {
const u = user();
if (u) {
console.log(`User changed: ${u.name}`);
localStorage.setItem('lastUser', JSON.stringify(u));
}
});
Rules:
- Effects run at least once (during creation)
- Effects track which signals they read and re-run when those change
- Must be created in an injection context (constructor, field initializer, or with
inject) - Use
untracked()to read a signal without tracking it
effect(() => {
const name = this.name(); // Tracked — triggers re-run
const config = untracked(() => this.config()); // NOT tracked — won't trigger
console.log(name, config);
});
Signals in Components
@Component({
template: `
<h1>{{ title() }}</h1>
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
<button (click)="reset()">Reset</button>
@if (isHigh()) {
<p class="warning">Count is high!</p>
}
`,
})
export class CounterComponent {
title = signal('My Counter');
count = signal(0);
double = computed(() => this.count() * 2);
isHigh = computed(() => this.count() > 10);
increment() { this.count.update(c => c + 1); }
reset() { this.count.set(0); }
}
> No async pipe needed — signals are synchronous. Just call signal() in the template.
Signal Inputs (Angular 17.1+)
import { input, output } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div>
<h3>{{ name() }}</h3>
<p>Role: {{ role() }}</p>
<button (click)="selectUser()">Select</button>
</div>
`,
})
export class UserCardComponent {
// Signal-based inputs
name = input.required<string>(); // Required
role = input<string>('user'); // Optional with default
disabled = input(false); // Inferred type
// Signal-based output
selected = output<string>();
// Computed from input signal
isAdmin = computed(() => this.role() === 'admin');
selectUser() {
this.selected.emit(this.name());
}
}
<app-user-card [name]="'Alice'" [role]="'admin'" (selected)="onSelect($event)" />
input() vs @Input()
input() (signal) | @Input() (decorator) | |
|---|---|---|
| Returns | InputSignal<T> | T |
| Reactive | ✅ Auto-tracked | ❌ Need ngOnChanges |
computed() | ✅ Works directly | ❌ Not reactive |
| Required | input.required<T>() | @Input({ required: true }) |
| Transform | input(0, { transform: numberAttribute }) | @Input({ transform }) |
Signal Queries (Angular 17.2+)
import { viewChild, viewChildren, contentChild, contentChildren } from '@angular/core';
@Component({ ... })
export class ParentComponent {
// Signal-based ViewChild
chart = viewChild<ChartComponent>('chart'); // Optional
input = viewChild.required<ElementRef>('myInput'); // Required
// Signal-based ViewChildren
items = viewChildren(ItemComponent); // Signal<readonly ItemComponent[]>
ngAfterViewInit() {
// No need for AfterViewInit — computed/effects react automatically
console.log(this.items().length);
}
focusInput() {
this.input().nativeElement.focus();
}
}
RxJS ↔ Signals Interop
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// Observable → Signal
@Component({ ... })
export class UserComponent {
private userService = inject(UserService);
// Convert Observable to Signal (auto-subscribes, auto-unsubscribes)
users = toSignal(this.userService.getAll(), { initialValue: [] });
// users() → User[]
// With error handling
user = toSignal(
this.userService.getById('123').pipe(catchError(() => of(null))),
);
}
// Signal → Observable
export class SearchComponent {
query = signal('');
// Convert Signal to Observable
query$ = toObservable(this.query);
results = toSignal(
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.searchService.search(q)),
),
{ initialValue: [] },
);
}
Signal-Based Component Pattern
@Component({
template: `
@if (vm(); as vm) {
<h1>{{ vm.user.name }}</h1>
<p>Orders: {{ vm.orderCount }}</p>
@for (order of vm.recentOrders; track order.id) {
<app-order-card [order]="order" />
}
} @else {
<app-spinner />
}
`,
})
export class UserDashboardComponent {
private userService = inject(UserService);
private orderService = inject(OrderService);
userId = input.required<string>();
private user = toSignal(
toObservable(this.userId).pipe(
switchMap(id => this.userService.getById(id)),
),
);
private orders = toSignal(
toObservable(this.userId).pipe(
switchMap(id => this.orderService.getByUser(id)),
),
{ initialValue: [] },
);
// View model — single computed for the template
vm = computed(() => {
const user = this.user();
if (!user) return null;
return {
user,
orderCount: this.orders().length,
recentOrders: this.orders().slice(0, 5),
};
});
}
Migrating from RxJS to Signals
| RxJS Pattern | Signal Equivalent |
|---|---|
BehaviorSubject | signal() |
Observable.pipe(map(...)) | computed() |
subscribe() (side effect) | effect() |
combineLatest([a$, b$]) | computed(() => [a(), b()]) |
async pipe | Just call signal() in template |
distinctUntilChanged | Built-in (signals skip equal values) |
Key Takeaways
- Signals are synchronous — no
asyncpipe, no subscription management signal()= state,computed()= derived,effect()= side effects- Use
input()andoutput()functions instead of@Input()/@Output()decorators toSignal()bridges observables into the signal world (HTTP, router, forms)computed()is lazy and memoized — only recalculates when dependencies change- Expose
asReadonly()signals from services to prevent external mutation - Signals work with
OnPushchange detection and future zoneless Angular