Signals
Signals are Angular's modern reactive primitive, introduced as stable in Angular 17. A signal is a wrapper around a value that notifies consumers whenever the value changes. Unlike RxJS observables, signals are synchronous, always have a current value, and require zero subscriptions or cleanup.
Signals fundamentally change how Angular handles reactivity and change detection — paving the way for a simpler, more performant Angular.
What Is a Signal?
Think of a signal as a "smart variable" — it holds a value and keeps track of who is reading it. When the value changes, all readers are automatically notified and updated.
import { signal } from '@angular/core';
// Create a signal with an initial value
const count = signal(0);
// Read the value by calling it like a function
console.log(count()); // 0
// Update the value
count.set(5);
console.log(count()); // 5
// Update based on previous value
count.update(current => current + 1);
console.log(count()); // 6Signals in Components
Signals are most commonly used inside components to manage local reactive state:
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>Count: {{ count() }}</h2>
<p>Double: {{ double() }}</p>
<p>Status: {{ status() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
`,
})
export class CounterComponent {
// Writable signal
count = signal(0);
// Computed signals (derived, read-only)
double = computed(() => this.count() * 2);
status = computed(() => this.count() > 0 ? 'positive' : this.count() < 0 ? 'negative' : 'zero');
increment() { this.count.update(n => n + 1); }
decrement() { this.count.update(n => n - 1); }
reset() { this.count.set(0); }
}count() — not count. Angular's template compiler recognizes signal calls and automatically subscribes to change notifications.The Three Signal APIs
API | Description | Writable? |
|---|---|---|
signal(initialValue) | Creates a writable signal | Yes — .set(), .update(), .mutate() |
computed(() => ...) | Creates a derived read-only signal | No — recalculates automatically |
effect(() => ...) | Runs a side-effect when signals change | No — runs but returns void |
Writable Signal Methods
import { signal } from '@angular/core';
const name = signal('Alice');
const scores = signal<number[]>([10, 20, 30]);
// .set() — replace the value entirely
name.set('Bob');
// .update() — compute new value from current value
name.update(current => current.toUpperCase()); // 'BOB'
// .update() with objects
const user = signal({ name: 'Alice', age: 25 });
user.update(u => ({ ...u, age: u.age + 1 })); // { name: 'Alice', age: 26 }
// Arrays — always create a new reference for immutable updates
scores.update(s => [...s, 40]); // [10, 20, 30, 40]
scores.update(s => s.filter(n => n > 15)); // [20, 30, 40]update(). Do not mutate in place — Angular will not detect the change.Read-Only Signals
Services often expose signals as read-only to prevent external mutation. Use asReadonly():
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AuthService {
// Private writable signal
private _user = signal<User | null>(null);
private _isLoading = signal(false);
// Public read-only signals
readonly user = this._user.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly isLoggedIn = computed(() => this._user() !== null);
async login(email: string, password: string): Promise<void> {
this._isLoading.set(true);
try {
const user = await this.authApi.login(email, password);
this._user.set(user);
} finally {
this._isLoading.set(false);
}
}
logout(): void {
this._user.set(null);
}
}Signals vs RxJS Observables
Aspect | Signals | Observables |
|---|---|---|
Always has a value | Yes — call it to get current value | No — must subscribe and wait |
Synchronous reads | Yes | No — async by nature |
Cleanup required | No | Yes — must unsubscribe |
Glitch-free | Yes — consistent state | Possible diamond problem |
Lazy evaluation | Computed only recalculates if consumed | Cold observables are lazy |
Async operations | Use toSignal() wrapper | Native — built for async |
Rich operators | Limited (computed, effect) | Vast (map, filter, switchMap...) |
Template syntax | count() | stream$ | async |
Converting Between Signals and Observables
Angular provides utilities in @angular/core/rxjs-interop to bridge signals and observables:
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { inject, Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@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);
// Convert Observable to Signal — no async pipe or subscribe() needed
users = toSignal(this.http.get<User[]>('/api/users'), {
initialValue: null,
});
}import { toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';
import { debounceTime, switchMap } from 'rxjs';
// Component with search using signal + Observable operators
@Component({ selector: 'app-search' })
export class SearchComponent {
searchTerm = signal('');
// Convert signal to Observable to use RxJS operators
results = toSignal(
toObservable(this.searchTerm).pipe(
debounceTime(300),
switchMap(term => this.http.get<Result[]>(`/api/search?q=${term}`))
),
{ initialValue: [] as Result[] }
);
}Signal-Based Inputs and Outputs (Angular 17.1+)
Angular 17.1 introduced signal-based input() and output() to replace the decorator-based @Input() and @Output():
import { Component, input, output, model } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<h3>{{ product().name }}</h3>
<p>{{ product().price }}</p>
<button (click)="onAddToCart()">Add to Cart</button>
</div>
`,
})
export class ProductCardComponent {
// Signal input — read-only signal in the template
product = input.required<Product>();
// Optional input with default
highlight = input(false);
// Output — emits events
addToCart = output<Product>();
onAddToCart() {
this.addToCart.emit(this.product());
}
}
// Two-way binding with model()
@Component({ selector: 'app-toggle' })
export class ToggleComponent {
checked = model(false); // both readable and writable from outside
toggle() {
this.checked.update(v => !v);
}
}Practical Example: Shopping Cart with Signals
import { Injectable, signal, computed } from '@angular/core';
export interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly itemCount = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly total = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly isEmpty = computed(() => this._items().length === 0);
addItem(product: { id: number; name: string; price: number }): void {
this._items.update(items => {
const existing = items.find(i => i.productId === product.id);
if (existing) {
return items.map(i =>
i.productId === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { productId: product.id, name: product.name, price: product.price, quantity: 1 }];
});
}
removeItem(productId: number): void {
this._items.update(items => items.filter(i => i.productId !== productId));
}
clear(): void {
this._items.set([]);
}
}@Component({
selector: 'app-cart-icon',
standalone: true,
template: `
<button class="cart-btn">
Cart ({{ cart.itemCount() }})
@if (!cart.isEmpty()) {
<span class="total">{{ cart.total() | currency }}</span>
}
</button>
`,
})
export class CartIconComponent {
protected cart = inject(CartService);
}Summary
Signals are synchronous reactive containers — call them to read, use .set() or .update() to write
computed() creates derived read-only signals that recalculate automatically
effect() runs side effects when signal dependencies change
Use asReadonly() to expose signals publicly without allowing external mutation
Convert between Observables and signals with toSignal() and toObservable()
Signal inputs (input()) and model() replace @Input() and @Output() in modern Angular
Signals enable fine-grained change detection — only components reading a changed signal re-render