16.7 Angular Signals — New Reactive System, Signals vs RxJS Comparison
What Are Signals?
Signals is a new reactive state management system introduced in Angular 16. A Signal is a special wrapper that holds a value and automatically notifies interested parties when that value changes.
Why the Angular team introduced Signals:
- Reduce Zone.js dependency: Angular currently uses Zone.js for change detection; Signals enables finer-grained change detection
- Intuitive state management: Simpler API than RxJS
- Better performance: Only re-renders components that use a changed Signal
Version History
| Version | Status |
|---|---|
| Angular 16 | Signals introduced experimentally |
| Angular 17 | Signals stable, input(), output() added |
| Angular 17.1 | Signal-based inputs stabilized |
| Angular 18 | linkedSignal, Resource API (experimental) |
| Angular 19 | linkedSignal stabilized, Resource API improved |
Core API — signal(), computed(), effect()
signal() — Mutable State
import { signal } from '@angular/core';
// Create basic Signals
const count = signal(0);
const name = signal('Angular');
const items = signal<string[]>([]);
const user = signal<User | null>(null);
// Read value (call as function)
console.log(count()); // 0
console.log(name()); // 'Angular'
// Change value: set()
count.set(5);
name.set('Signals');
// Update based on previous value: update()
count.update(n => n + 1);
items.update(list => [...list, 'new item']);
// Expose as read-only Signal (asReadonly)
const readonlyCount = count.asReadonly();
// readonlyCount.set() → Type error!
computed() — Derived State (Read-Only)
import { signal, computed } from '@angular/core';
const price = signal(10);
const quantity = signal(3);
const discountRate = signal(0.1); // 10%
// Derived computed — automatically recalculates when price, quantity, or discountRate changes
const subtotal = computed(() => price() * quantity());
const discount = computed(() => subtotal() * discountRate());
const total = computed(() => subtotal() - discount());
console.log(total()); // 27
price.set(20);
console.log(total()); // 54 — automatically updated!
// computed is not writable
// total.set(999); → Type error
effect() — Side Effects
import { signal, computed, effect } from '@angular/core';
import { Component, OnInit } from '@angular/core';
@Component({ ... })
export class MyComponent {
theme = signal<'light' | 'dark'>('light');
userName = signal('');
constructor() {
// effect: runs automatically when Signal values change
effect(() => {
// Reading theme() → theme is automatically registered as a dependency
document.body.classList.toggle('dark-theme', this.theme() === 'dark');
console.log('Theme changed:', this.theme());
});
// Sync with localStorage
effect(() => {
const name = this.userName();
if (name) {
localStorage.setItem('username', name);
}
});
// Register cleanup function
effect((onCleanup) => {
const timer = setInterval(() => console.log('tick'), 1000);
onCleanup(() => clearInterval(timer)); // Cleanup before effect re-runs
});
}
}
State Updates — set(), update(), mutate()
import { signal } from '@angular/core';
// Primitive types
const counter = signal(0);
counter.set(10); // Set directly
counter.update(n => n + 1); // Update based on previous value
// Arrays
const todos = signal<Todo[]>([]);
// Add item
todos.update(list => [...list, newTodo]);
// Modify item
todos.update(list =>
list.map(t => t.id === id ? { ...t, completed: true } : t)
);
// Remove item
todos.update(list => list.filter(t => t.id !== id));
// Objects
const user = signal({ name: 'Alice', age: 30 });
// Partial update
user.update(u => ({ ...u, age: 31 }));
input() — Signal-Based Input (Angular 17.1+)
import { Component, input, computed } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
stock: number;
}
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div [class.out-of-stock]="isOutOfStock()">
<h3>{{ product().name }}</h3>
<p class="price">{{ formattedPrice() }}</p>
<p class="stock">Stock: {{ product().stock }}</p>
@if (showActions()) {
<button [disabled]="isOutOfStock()">
{{ isOutOfStock() ? 'Out of Stock' : 'Add to Cart' }}
</button>
}
</div>
`
})
export class ProductCardComponent {
// Required input signal
product = input.required<Product>();
// Optional input signal (with default)
showActions = input(true);
currency = input('USD');
// computed — derived from input signals
isOutOfStock = computed(() => this.product().stock === 0);
formattedPrice = computed(() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: this.currency()
}).format(this.product().price)
);
}
output() — Event Emission (Angular 17.1+)
import { Component, output, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div>
<button (click)="decrement()">-</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
count = signal(0);
// Define events with output() function
countChanged = output<number>();
resetted = output<void>();
increment() {
this.count.update(n => n + 1);
this.countChanged.emit(this.count());
}
decrement() {
this.count.update(n => n - 1);
this.countChanged.emit(this.count());
}
reset() {
this.count.set(0);
this.resetted.emit();
}
}
model() — Two-Way Signal Binding (Angular 17.2+)
import { Component, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-toggle',
standalone: true,
template: `
<label>
<input
type="checkbox"
[checked]="checked()"
(change)="checked.set($any($event.target).checked)"
>
{{ checked() ? 'On' : 'Off' }}
</label>
`
})
export class ToggleComponent {
// model(): two-way signal readable and writable from parent
checked = model(false);
}
// Usage in parent component
@Component({
standalone: true,
imports: [ToggleComponent],
template: `
<app-toggle [(checked)]="isDarkMode" />
<p>Dark mode: {{ isDarkMode() ? 'Active' : 'Inactive' }}</p>
`
})
export class ParentComponent {
isDarkMode = signal(false);
}
toSignal(), toObservable() — RxJS Interop
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { signal, computed } from '@angular/core';
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({ ... })
export class InteropComponent {
// Observable → Signal
// tick updates every second as a Signal
tick = toSignal(interval(1000), { initialValue: 0 });
// HTTP response as Signal
users = toSignal(
inject(HttpClient).get<User[]>('/api/users'),
{ initialValue: [] as User[] }
);
// Signal → Observable
searchTerm = signal('');
// Convert searchTerm Signal to Observable → use RxJS operators
searchResults$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term =>
term ? inject(HttpClient).get<Product[]>(`/api/search?q=${term}`) : of([])
)
);
// Back to Signal
searchResults = toSignal(this.searchResults$, { initialValue: [] });
}
linkedSignal — Linked Signal (Angular 19)
linkedSignal creates a Signal that depends on another Signal but can also be independently modified.
import { signal, linkedSignal } from '@angular/core';
const options = signal(['Apple', 'Banana', 'Orange']);
// Automatically resets to the first option when options changes
// but users can still select manually
const selectedOption = linkedSignal(() => options()[0]);
console.log(selectedOption()); // 'Apple'
selectedOption.set('Banana'); // User selects manually
console.log(selectedOption()); // 'Banana'
options.set(['Strawberry', 'Grape', 'Kiwi']); // options changes
console.log(selectedOption()); // 'Strawberry' — auto reset
Signals vs RxJS — When to Use Which
| Criterion | Signals | RxJS |
|---|---|---|
| Learning difficulty | Low | High |
| Code readability | High | Medium (operator chains) |
| Sync/Async | Primarily sync | Great for async |
| Stream processing | Limited | Powerful |
| Error handling | Simple | Powerful |
| Cancellation | Not supported | Supported |
| Time-based | Manual implementation | debounce, throttle, etc. built-in |
| Combining | computed | combineLatest, forkJoin, etc. |
When to Use Signals
// ✅ Signals are a good fit for:
// 1. Component local state
count = signal(0);
isOpen = signal(false);
// 2. Derived state
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
// 3. Shared service state
@Injectable({ providedIn: 'root' })
export class AuthService {
private _user = signal<User | null>(null);
readonly user = this._user.asReadonly();
}
// 4. Simple async (with toSignal)
users = toSignal(inject(HttpClient).get<User[]>('/api/users'), { initialValue: [] });
When to Use RxJS
// ✅ RxJS is a good fit for:
// 1. Search autocomplete (debounce + switchMap)
searchResults$ = searchControl.valueChanges.pipe(
debounceTime(300),
switchMap(term => this.http.get(`/api/search?q=${term}`))
);
// 2. Combining multiple sources
dashboardData$ = combineLatest([users$, orders$, stats$]).pipe(
map(([users, orders, stats]) => ({ users, orders, stats }))
);
// 3. WebSocket real-time data
messages$ = webSocket('wss://api.example.com').pipe(
retryWhen(errors => errors.pipe(delay(3000)))
);
// 4. Complex async workflows
submitOrder$ = cartItems$.pipe(
switchMap(items => this.validateItems(items)),
switchMap(validItems => this.processPayment(validItems)),
switchMap(paymentResult => this.createOrder(paymentResult))
);
Real-World Example — Shopping Cart with Signals
// services/cart.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
image: string;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>([]);
// Expose as read-only
readonly items = this._items.asReadonly();
// Derived state
readonly itemCount = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly subtotal = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly tax = computed(() => this.subtotal() * 0.1);
readonly total = computed(() => this.subtotal() + this.tax());
readonly isEmpty = computed(() => this._items().length === 0);
// Add item to cart
addItem(product: Omit<CartItem, 'quantity'>) {
this._items.update(items => {
const existing = items.find(i => i.id === product.id);
if (existing) {
// Increment quantity if already present
return items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
// Add new item
return [...items, { ...product, quantity: 1 }];
});
}
// Update quantity
updateQuantity(id: number, quantity: number) {
if (quantity <= 0) {
this.removeItem(id);
return;
}
this._items.update(items =>
items.map(i => i.id === id ? { ...i, quantity } : i)
);
}
// Remove item
removeItem(id: number) {
this._items.update(items => items.filter(i => i.id !== id));
}
// Clear cart
clearCart() {
this._items.set([]);
}
}
// cart/cart.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CartService } from '../services/cart.service';
@Component({
selector: 'app-cart',
standalone: true,
imports: [FormsModule],
template: `
<div class="cart">
<h2>Shopping Cart ({{ cartService.itemCount() }} items)</h2>
@if (cartService.isEmpty()) {
<div class="empty-cart">
<p>Your cart is empty.</p>
<a href="/products">Continue shopping</a>
</div>
} @else {
<div class="cart-items">
@for (item of cartService.items(); track item.id) {
<div class="cart-item">
<img [src]="item.image" [alt]="item.name" class="item-image">
<div class="item-info">
<h3>{{ item.name }}</h3>
<p class="price">{{ item.price | currency }}</p>
</div>
<div class="quantity-control">
<button (click)="cartService.updateQuantity(item.id, item.quantity - 1)">
-
</button>
<input
type="number"
[value]="item.quantity"
(change)="onQuantityChange(item.id, $event)"
min="1"
class="qty-input"
>
<button (click)="cartService.updateQuantity(item.id, item.quantity + 1)">
+
</button>
</div>
<p class="item-total">
{{ item.price * item.quantity | currency }}
</p>
<button (click)="cartService.removeItem(item.id)" class="remove-btn">
Remove
</button>
</div>
}
</div>
<!-- Order summary -->
<div class="cart-summary">
<div class="summary-row">
<span>Subtotal</span>
<span>{{ cartService.subtotal() | currency }}</span>
</div>
<div class="summary-row">
<span>Tax (10%)</span>
<span>{{ cartService.tax() | currency }}</span>
</div>
<div class="summary-row total">
<span>Total</span>
<span>{{ cartService.total() | currency }}</span>
</div>
<div class="cart-actions">
<button (click)="cartService.clearCart()" class="clear-btn">
Clear Cart
</button>
<button (click)="checkout()" class="checkout-btn">
Checkout ({{ cartService.total() | currency }})
</button>
</div>
</div>
}
<!-- Order confirmation -->
@if (orderPlaced()) {
<div class="order-success">
Order placed! Order ID: {{ orderId() }}
</div>
}
</div>
`
})
export class CartComponent {
cartService = inject(CartService);
orderPlaced = signal(false);
orderId = signal('');
onQuantityChange(id: number, event: Event) {
const qty = Number((event.target as HTMLInputElement).value);
this.cartService.updateQuantity(id, qty);
}
checkout() {
const orderId = `ORD-${Date.now()}`;
this.orderId.set(orderId);
this.orderPlaced.set(true);
this.cartService.clearCart();
}
}
Pro Tips
Tip 1: Avoid Writing to Signals Inside effect
// ❌ Writing a signal inside effect → risk of infinite loop
effect(() => {
const val = count();
count.set(val + 1); // Dangerous!
});
// ✅ Use computed instead
const doubled = computed(() => count() * 2);
// ✅ If unavoidable, use the allowSignalWrites option
effect(() => {
// ...
}, { allowSignalWrites: true });
Tip 2: Expose Service Signals as asReadonly()
@Injectable({ providedIn: 'root' })
export class UserService {
private _user = signal<User | null>(null);
// External code reads only; changes go through service methods
readonly user = this._user.asReadonly();
setUser(user: User) {
this._user.set(user);
}
}
Tip 3: Signals Work Without Zone.js
Angular 18 supports experimental zoneless mode:
// app.config.ts
provideExperimentalZonelessChangeDetection()