Angular Signals: A Deep Dive
Angular Signals represent a significant paradigm shift in how Angular handles reactivity. Introduced in Angular 16 and expanded in subsequent versions, Signals provide a fine-grained reactivity system that brings numerous benefits to Angular applications. This article explores the Signal API in depth, with practical TypeScript examples to help you master this powerful feature.
What Are Signals and Why Should You Care?
Signals are a primitive for reactive programming that hold a value and notify consumers when that value changes. They're designed to be:
- Lightweight: Minimal overhead when creating and updating values
- Fine-grained: Only affected components rerender, not the entire view
- Transparent: Clear connections between reactive values and their dependencies
- Synchronous: Changes propagate immediately in a predictable manner
- Compatible: Work alongside existing Angular features without requiring a complete rewrite
Before Signals, Angular's reactivity was primarily based on Zone.js and change detection, which could sometimes be inefficient and opaque. Signals provide a more explicit, efficient alternative.
Core Concepts
At their core, Signals are functions that:
- Return their current value when called
- Track when they're read during rendering or computation
- Notify listeners when their value changes
Let's examine the fundamental building blocks:
1. Creating a Signal
import { signal } from '@angular/core';
// Creating a typed signal
const count = signal<number>(0);
// Creating a signal with inferred type
const name = signal('Angular');
// Reading a signal's value
console.log(count()); // 0
console.log(name()); // 'Angular'
2. Updating a Signal
// Direct value update
count.set(5);
// Update based on previous value
count.update(value => value + 1);
// For objects, use mutate to modify in place
const user = signal({ name: 'Alice', role: 'Admin' });
user.mutate(value => {
value.role = 'Developer';
});
3. Computed Signals
Computed signals derive their value from other signals:
import { signal, computed } from '@angular/core';
const price = signal(100);
const taxRate = signal(0.1);
// Computed value that updates when dependencies change
const priceWithTax = computed(() => {
return price() * (1 + taxRate());
});
console.log(priceWithTax()); // 110
// When price changes, priceWithTax automatically updates
price.set(200);
console.log(priceWithTax()); // 220
4. Effect Handling
Effects allow you to respond to signal changes:
import { signal, effect } from '@angular/core';
const user = signal<string | null>(null);
// This effect runs initially and on every change to user
effect(() => {
if (user()) {
console.log(`Current user: ${user()}`);
} else {
console.log('No user logged in');
}
});
// Logs: "No user logged in"
user.set('Alice');
// Logs: "Current user: Alice"
Signal Types in TypeScript
Let's look at the TypeScript interfaces that define signals:
// Core Signal interface
export interface Signal<T> {
(): T;
[SIGNAL]: true;
}
// Writable Signal interface
export interface WritableSignal<T> extends Signal<T> {
set(value: T): void;
update(updateFn: (value: T) => T): void;
asReadonly(): Signal<T>;
}
// Mutable Signal interface (for objects)
export interface MutableSignal<T extends object> extends WritableSignal<T> {
mutate(mutatorFn: (value: T) => void): void;
}
Integrating Signals with Components
Angular 17+ provides a first-class integration with components:
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h1>Counter: {{ count() }}</h1>
<p>Doubled: {{ doubled() }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
`,
})
export class CounterComponent {
// Component state as signals
count = signal(0);
// Derived state
doubled = computed(() => this.count() * 2);
// Methods that update signals
increment() {
this.count.update(n => n + 1);
}
decrement() {
this.count.update(n => n - 1);
}
}
Signal Inputs
Angular 17 introduced signal inputs, which provide reactive inputs without the need for ngOnChanges:
import { Component, input } from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: '<h1>Hello, {{ name() }}!</h1>',
})
export class GreetingComponent {
// Signal input with default value
name = input('Guest');
// Required input with type safety
userId = input.required<number>();
// Input with transform function
age = input(0, {
transform: (value: string | number) =>
typeof value === 'string' ? parseInt(value, 10) : value
});
}
Signal Queries
For DOM queries, Angular provides signal-based alternatives to @ViewChild
and @ViewChildren
:
import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
import { ListItemComponent } from './list-item.component';
@Component({
selector: 'app-list',
standalone: true,
imports: [ListItemComponent],
template: `
<h1 #title>Items</h1>
<app-list-item *ngFor="let item of items" [item]="item"></app-list-item>
`,
})
export class ListComponent {
items = ['Apple', 'Banana', 'Cherry'];
// Signal-based ViewChild
titleElement = viewChild<ElementRef>('title');
// Signal-based ViewChildren
listItems = viewChildren(ListItemComponent);
ngAfterViewInit() {
// Access as signals
console.log(this.titleElement()?.nativeElement.textContent);
console.log('Item count:', this.listItems().length);
}
}
Signals vs. RxJS: When to Use Each
Both Signals and RxJS handle reactivity, but they serve different purposes:
Aspect | Signals | RxJS |
---|---|---|
Complexity | Simple values with dependencies | Complex event streams |
Learning Curve | Lower | Higher |
Use Cases | UI state, derived values | Async operations, event handling |
Debugging | Easier to trace | More powerful but complex |
Operators | Limited built-in | Rich ecosystem of operators |
Consider using:
- Signals for component state, UI interactions, and derived values
- RxJS for HTTP requests, complex event sequences, and time-based operations
They can be used together:
import { signal } from '@angular/core';
import { timer } from 'rxjs';
const counter = signal(0);
// Connect RxJS Observable to a Signal
const subscription = timer(0, 1000).subscribe(value => {
counter.set(value);
});
// Later: subscription.unsubscribe();
Advanced Patterns with Signals
1. Signal-based Services
import { Injectable, signal, computed } from '@angular/core';
interface User {
id: number;
name: string;
isAdmin: boolean;
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private userSignal = signal<User | null>(null);
// Public readonly APIs
readonly user = this.userSignal.asReadonly();
readonly isLoggedIn = computed(() => this.userSignal() !== null);
readonly isAdmin = computed(() => this.userSignal()?.isAdmin ?? false);
login(user: User): void {
this.userSignal.set(user);
}
logout(): void {
this.userSignal.set(null);
}
updateUser(updates: Partial<User>): void {
this.userSignal.update(user => {
if (!user) return null;
return { ...user, ...updates };
});
}
}
2. Complex Form State
import { Component, signal, computed } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-registration',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Form controls here -->
<button type="submit" [disabled]="isSubmitDisabled()">
{{ submitButtonText() }}
</button>
</form>
`
})
export class RegistrationComponent {
form: FormGroup;
isSubmitting = signal(false);
// Computed properties based on form and submission state
isSubmitDisabled = computed(() =>
this.form.invalid || this.isSubmitting()
);
submitButtonText = computed(() =>
this.isSubmitting() ? 'Submitting...' : 'Register'
);
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
}
onSubmit() {
if (this.form.invalid) return;
this.isSubmitting.set(true);
// Submit logic here
// Simulate API call
setTimeout(() => {
this.isSubmitting.set(false);
}, 1500);
}
}
3. Server State Management
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
export interface RequestState<T> {
status: RequestStatus;
data: T | null;
error: any;
}
@Injectable({
providedIn: 'root'
})
export class ProductService {
private productsState = signal<RequestState<Product[]>>({
status: 'idle',
data: null,
error: null
});
// Public APIs
readonly products = computed(() => this.productsState().data || []);
readonly status = computed(() => this.productsState().status);
readonly isLoading = computed(() => this.status() === 'loading');
readonly error = computed(() => this.productsState().error);
constructor(private http: HttpClient) {}
fetchProducts() {
// Set loading state
this.productsState.set({
status: 'loading',
data: this.productsState().data, // Keep existing data
error: null
});
this.http.get<Product[]>('/api/products').subscribe({
next: (data) => {
this.productsState.set({
status: 'success',
data,
error: null
});
},
error: (error) => {
this.productsState.set({
status: 'error',
data: this.productsState().data,
error
});
}
});
}
}
interface Product {
id: number;
name: string;
price: number;
}
Performance Considerations
Signals offer performance benefits, but require careful consideration:
-
Minimize Signal Reads in Templates
Too many signal reads can degrade performance.// Inefficient - multiple reads in template <div>{{ user().firstName }} {{ user().lastName }}</div> // Better - one signal read <div>{{ fullName() }}</div> // In component user = signal({ firstName: 'John', lastName: 'Doe' }); fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);
-
Avoid Expensive Computations
Computed signals should be lightweight.// Inefficient - expensive operation on every read expensiveData = computed(() => { return this.items().filter(item => complexCalculation(item) ); }); // Better - cache intermediate results processedItems = computed(() => { const items = this.items(); if (this.lastItemsLength === items.length) { return this.cachedResult; } this.lastItemsLength = items.length; this.cachedResult = items.filter(item => complexCalculation(item)); return this.cachedResult; });
-
Use
untracked()
When Necessary
To prevent unnecessary recomputation:import { signal, computed, untracked } from '@angular/core'; const counter = signal(0); const logger = signal<string[]>([]); // Without untracked, this would recompute when logger changes const displayValue = computed(() => { const count = counter(); // Access logger without creating a dependency untracked(() => { console.log('Current logs:', logger().length); }); return `Counter: ${count}`; });
Best Practices
1. Signal Naming Conventions
// Component state signals
count = signal(0);
isLoading = signal(false);
users = signal<User[]>([]);
// Computed values - descriptive names showing derivation
totalUsers = computed(() => this.users().length);
hasUsers = computed(() => this.totalUsers() > 0);
filteredUsers = computed(() => /* filtering logic */);
// Service APIs - use readonly for public signals
readonly currentUser = this.userSignal.asReadonly();
2. Enforce Immutability
// DO: Create new objects when updating signals
updateUser(id: number, updates: Partial<User>) {
this.users.update(users =>
users.map(user =>
user.id === id ? { ...user, ...updates } : user
)
);
}
// DON'T: Mutate objects outside signal operations
const users = this.users();
users[0].name = 'New Name'; // ❌ Won't trigger updates
// DO: Use mutate for performance when appropriate
this.users.mutate(users => {
users[0].name = 'New Name';
});
3. Organizing Complex Component State
For components with complex state, consider encapsulating related signals:
interface UserState {
users: User[];
selectedId: number | null;
filter: string;
isLoading: boolean;
}
@Component({
selector: 'app-user-dashboard',
template: `<!-- template here -->`
})
export class UserDashboardComponent {
// Group related state
private state = signal<UserState>({
users: [],
selectedId: null,
filter: '',
isLoading: false
});
// Expose computed properties
readonly users = computed(() => this.state().users);
readonly selectedUser = computed(() => {
const id = this.state().selectedId;
return id !== null
? this.state().users.find(u => u.id === id) || null
: null;
});
readonly filteredUsers = computed(() => {
const filter = this.state().filter.toLowerCase();
return filter
? this.state().users.filter(u =>
u.name.toLowerCase().includes(filter))
: this.state().users;
});
// Methods to update state
setFilter(filter: string) {
this.state.update(s => ({ ...s, filter }));
}
selectUser(id: number | null) {
this.state.update(s => ({ ...s, selectedId: id }));
}
}
Migrating from RxJS to Signals
If you're transitioning from RxJS to Signals, here's a pattern for gradual migration:
import { Injectable, signal, computed, effect } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class CounterService {
// Legacy RxJS approach
private counterSubject = new BehaviorSubject(0);
counter$ = this.counterSubject.asObservable();
// New Signal approach
private counterSignal = signal(0);
readonly counter = this.counterSignal.asReadonly();
readonly doubledCounter = computed(() => this.counterSignal() * 2);
constructor() {
// Bridge from RxJS to Signals
this.counter$.subscribe(value => {
this.counterSignal.set(value);
});
// Bridge from Signals to RxJS
effect(() => {
const value = this.counterSignal();
if (value !== this.counterSubject.value) {
this.counterSubject.next(value);
}
});
}
increment() {
// Update both for compatibility
const newValue = this.counterSubject.value + 1;
this.counterSubject.next(newValue);
// Signal is updated via the subscription
}
}
Future of Signals in Angular
Angular is committed to expanding the Signal-based architecture:
- More Signal-Based APIs: Expect more of Angular's core to adopt Signals
- Signal-Based Router: Angular 18+ brings a router driven by signals
- Observable Interoperability: Better bridges between Signals and RxJS
- Server-Side Integration: Signal-optimized server-side rendering
- Compiler Optimizations: Smart detection and optimization of signals
The Gist
Angular Signals represent a major evolution in Angular's reactivity system, bringing fine-grained updates and clearer reactive programming patterns to the framework. While they don't replace RxJS entirely, they offer an elegant solution for component state management and UI reactivity.
By embracing Signals in your Angular applications, you can:
- Create more performant applications with less boilerplate
- Build more maintainable and predictable components
- Establish clearer relationships between state and derived values
- Gradually migrate from zone-based change detection to a more explicit reactive model
As you implement Signals in your applications, focus on establishing clear patterns, maintaining immutability, and balancing between Signals and RxJS for the right use cases. With these practices in mind, you'll be well-positioned to leverage the full power of Angular's modern reactivity system.