Skip to main content
Advertisement

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

VersionStatus
Angular 16Signals introduced experimentally
Angular 17Signals stable, input(), output() added
Angular 17.1Signal-based inputs stabilized
Angular 18linkedSignal, Resource API (experimental)
Angular 19linkedSignal 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

CriterionSignalsRxJS
Learning difficultyLowHigh
Code readabilityHighMedium (operator chains)
Sync/AsyncPrimarily syncGreat for async
Stream processingLimitedPowerful
Error handlingSimplePowerful
CancellationNot supportedSupported
Time-basedManual implementationdebounce, throttle, etc. built-in
CombiningcomputedcombineLatest, 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()
Advertisement