Angular Signals: The Complete Guide from Beginner to Advanced
Master Angular Signals from basics to advanced patterns. Learn reactive programming, avoid common pitfalls, and make the right decisions with our comprehensive guide.
What Are Angular Signals?
Angular Signals represent a fundamental shift in how Angular handles reactivity and change detection. Introduced in Angular 16 and becoming stable in Angular 17, Signals provide a fine-grained reactivity system that's simpler, more performant, and more predictable than traditional Zone.js-based change detection.
Why Signals Matter šÆ
- Performance: Fine-grained updates only where data changes
- Simplicity: No more
ChangeDetectorRefgymnastics - Predictability: Clear data flow and dependencies
- Developer Experience: Better TypeScript inference and debugging
- Future-proof: The direction Angular is heading
Beginner: Understanding Signal Basics
Creating Your First Signal
A signal is a wrapper around a value that notifies interested consumers when that value changes.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<button (click)="increment()">Increment</button>
</div>
`
})
export class CounterComponent {
// Creating a signal
count = signal(0);
increment() {
// Updating a signal
this.count.set(this.count() + 1);
// Alternative: update based on current value
// this.count.update(value => value + 1);
}
}
Key Points:
- Call
signal()with an initial value - Read signal value by calling it like a function:
count() - Update with
.set()or.update()
Signal Methods: set() vs update()
export class SignalMethodsComponent {
counter = signal(0);
user = signal({ name: 'John', age: 30 });
// ā
GOOD: Use set() when you have the complete new value
resetCounter() {
this.counter.set(0);
}
// ā
GOOD: Use update() when new value depends on old value
incrementCounter() {
this.counter.update(current => current + 1);
}
// ā BAD: Don't read and set separately (race condition risk)
badIncrement() {
const current = this.counter();
this.counter.set(current + 1); // Not atomic!
}
// ā
GOOD: update() is atomic and safe
updateUserAge() {
this.user.update(u => ({ ...u, age: u.age + 1 }));
}
}
Computed Signals
Computed signals derive their value from other signals and automatically update.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-shopping-cart',
template: `
<div>
<p>Items: {{ itemCount() }}</p>
<p>Price: {{ itemPrice() }}</p>
<p>Total: {{ total() }}</p>
<p>Status: {{ status() }}</p>
</div>
`
})
export class ShoppingCartComponent {
itemCount = signal(3);
itemPrice = signal(25.99);
// Computed signal - automatically recalculates
total = computed(() => this.itemCount() * this.itemPrice());
// Computed can depend on other computed signals
status = computed(() => {
const t = this.total();
return t > 100 ? 'Premium Order' : 'Standard Order';
});
}
Computed Signal Features:
- ā Automatically tracks dependencies
- ā Memoized (only recalculates when dependencies change)
- ā Lazy evaluation (only runs when read)
- ā Read-only (cannot be set directly)
Effects: Reacting to Signal Changes
Effects run side effects when signals change.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-logger',
template: `<button (click)="increment()">Count: {{ count() }}</button>`
})
export class LoggerComponent {
count = signal(0);
constructor() {
// Effect runs whenever count changes
effect(() => {
console.log(`Count changed to: ${this.count()}`);
// Save to localStorage
localStorage.setItem('count', this.count().toString());
});
}
increment() {
this.count.update(c => c + 1);
}
}
ā ļø Effect Rules:
- Effects run in injection context (constructor, field initializers)
- Don't use effects for state derivation (use
computedinstead) - Effects are for side effects only (logging, storage, API calls)
Intermediate: Advanced Signal Patterns
Signal Inputs (Angular 17.1+)
Replace @Input() with signal-based inputs for better reactivity.
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div>
<h3>{{ fullName() }}</h3>
<p>Age: {{ age() }}</p>
<p>Required: {{ userName() }}</p>
<input [(ngModel)]="email" />
</div>
`
})
export class UserCardComponent {
// Required input
userName = input.required<string>();
// Optional input with default
age = input(18);
// Input with transform
firstName = input('', { transform: (v: string) => v.trim() });
lastName = input('', { transform: (v: string) => v.trim() });
// Two-way binding with model()
email = model<string>('user@example.com');
// Computed based on inputs
fullName = computed(() =>
`${this.firstName()} ${this.lastName()}`.trim()
);
}
Signal Inputs vs Traditional @Input():
| Feature | @Input() | input() |
|---|---|---|
| Type Safety | ā Good | ā Better (stricter) |
| Change Detection | Zone-based | Signal-based |
| Required Inputs | @Input({ required: true }) | input.required<T>() |
| Transform | @Input({ transform: fn }) | input(default, { transform: fn }) |
| Two-way Binding | @Input() + @Output() | model() |
Signal Queries (ViewChild, ContentChild)
import { Component, viewChild, viewChildren, contentChild } from '@angular/core';
import { ElementRef } from '@angular/core';
@Component({
selector: 'app-parent',
template: `
<input #searchInput type="text" />
<div #item class="item">Item 1</div>
<div #item class="item">Item 2</div>
<button (click)="focusSearch()">Focus Search</button>
<ng-content></ng-content>
`
})
export class ParentComponent {
// Single element query (replaces @ViewChild)
searchInput = viewChild<ElementRef>('searchInput');
// Required query
firstItem = viewChild.required<ElementRef>('item');
// Multiple elements (replaces @ViewChildren)
allItems = viewChildren<ElementRef>('item');
// Content query (replaces @ContentChild)
projectedContent = contentChild<ElementRef>('projected');
focusSearch() {
// Signal queries are always defined (or undefined)
this.searchInput()?.nativeElement.focus();
console.log(`Found ${this.allItems().length} items`);
}
}
Working with Arrays and Objects
import { Component, signal } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-todo-list',
template: `
<div *ngFor="let todo of todos()">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)" />
{{ todo.text }}
</div>
<p>Completed: {{ completedCount() }}</p>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([
{ id: 1, text: 'Learn Signals', completed: false },
{ id: 2, text: 'Build App', completed: false }
]);
completedCount = computed(() =>
this.todos().filter(t => t.completed).length
);
// ā
GOOD: Create new array reference
addTodo(text: string) {
this.todos.update(current => [
...current,
{ id: Date.now(), text, completed: false }
]);
}
// ā
GOOD: Immutable update pattern
toggleTodo(id: number) {
this.todos.update(current =>
current.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
// ā BAD: Mutating signal value directly
badToggle(id: number) {
const todos = this.todos();
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed; // Won't trigger updates!
}
}
}
Key Rule: Always create new object/array references when updating signals containing reference types.
Mutating Signals (Angular 17.2+)
For cases where immutability is impractical, use .mutate():
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-large-data',
template: `<p>Items: {{ data().items.length }}</p>`
})
export class LargeDataComponent {
data = signal<{ items: number[], metadata: any }>({
items: Array.from({ length: 10000 }, (_, i) => i),
metadata: {}
});
// ā
Use mutate() for performance with large data structures
addItem(item: number) {
this.data.mutate(d => {
d.items.push(item); // Direct mutation
});
}
// ā Spreading large arrays is expensive
inefficientAdd(item: number) {
this.data.update(d => ({
...d,
items: [...d.items, item] // Creates copy of 10000 items!
}));
}
}
ā ļø Use .mutate() sparingly - it opts out of immutability benefits.
Advanced: Complex Signal Patterns
Signal-based State Management
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
users: User[];
loading: boolean;
error: string | null;
selectedUserId: number | null;
}
@Injectable({ providedIn: 'root' })
export class UserStore {
// Private state signal
private state = signal<UserState>({
users: [],
loading: false,
error: null,
selectedUserId: null
});
// Public computed selectors
users = computed(() => this.state().users);
loading = computed(() => this.state().loading);
error = computed(() => this.state().error);
selectedUser = computed(() => {
const state = this.state();
return state.users.find(u => u.id === state.selectedUserId) ?? null;
});
constructor(private http: HttpClient) {}
// Actions
async loadUsers() {
this.state.update(s => ({ ...s, loading: true, error: null }));
try {
const users = await this.http.get<User[]>('/api/users').toPromise();
this.state.update(s => ({ ...s, users, loading: false }));
} catch (error) {
this.state.update(s => ({
...s,
loading: false,
error: 'Failed to load users'
}));
}
}
selectUser(id: number) {
this.state.update(s => ({ ...s, selectedUserId: id }));
}
updateUser(id: number, updates: Partial<User>) {
this.state.update(s => ({
...s,
users: s.users.map(u => u.id === id ? { ...u, ...updates } : u)
}));
}
}
Signals with RxJS Interop
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';
@Component({
selector: 'app-rxjs-interop',
template: `
<div>
<p>Search: <input [(ngModel)]="searchTerm" /></p>
<p>Results: {{ searchResults() }}</p>
<p>Timer: {{ timer() }}</p>
</div>
`
})
export class RxjsInteropComponent {
searchTerm = signal('');
// Convert signal to observable
searchTerm$ = toObservable(this.searchTerm);
// Use with RxJS operators
searchResults$ = this.searchTerm$.pipe(
map(term => `Searching for: ${term}`)
);
// Convert observable back to signal
searchResults = toSignal(this.searchResults$, { initialValue: '' });
// Observable to signal
timer = toSignal(
interval(1000).pipe(map(n => n)),
{ initialValue: 0 }
);
}
When to Use Each:
Effect Cleanup and Lifecycle
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-websocket',
template: `<p>Messages: {{ messageCount() }}</p>`
})
export class WebSocketComponent {
messageCount = signal(0);
private ws: WebSocket | null = null;
constructor() {
effect((onCleanup) => {
// Setup: runs when effect executes
console.log('Connecting to WebSocket...');
this.ws = new WebSocket('ws://example.com');
this.ws.onmessage = () => {
this.messageCount.update(c => c + 1);
};
// Cleanup: runs before next execution or on destroy
onCleanup(() => {
console.log('Closing WebSocket...');
this.ws?.close();
this.ws = null;
});
});
}
}
Custom Signal Utilities
import { signal, computed, Signal } from '@angular/core';
// Debounced signal
export function debouncedSignal<T>(initialValue: T, delayMs: number) {
const immediate = signal(initialValue);
const debounced = signal(initialValue);
let timeoutId: any;
return {
set: (value: T) => {
immediate.set(value);
clearTimeout(timeoutId);
timeoutId = setTimeout(() => debounced.set(value), delayMs);
},
immediate: immediate.asReadonly(),
debounced: debounced.asReadonly()
};
}
// Async signal
export function asyncSignal<T>(promiseFn: () => Promise<T>, initialValue: T) {
const value = signal(initialValue);
const loading = signal(false);
const error = signal<Error | null>(null);
const load = async () => {
loading.set(true);
error.set(null);
try {
const result = await promiseFn();
value.set(result);
} catch (e) {
error.set(e as Error);
} finally {
loading.set(false);
}
};
return {
value: value.asReadonly(),
loading: loading.asReadonly(),
error: error.asReadonly(),
reload: load
};
}
// Usage
const search = debouncedSignal('', 300);
search.set('Angular'); // Immediate update
console.log(search.debounced()); // Updates after 300ms
const userData = asyncSignal(
() => fetch('/api/user').then(r => r.json()),
null
);
userData.reload();
Common Pitfalls and How to Avoid Them
ā Pitfall 1: Mutating Signal Values Directly
// ā BAD: Direct mutation doesn't trigger updates
const user = signal({ name: 'John', age: 30 });
user().age = 31; // Won't update!
// ā
GOOD: Create new object reference
user.update(u => ({ ...u, age: 31 }));
ā Pitfall 2: Using Effects for Derived State
// ā BAD: Using effect for derived state
const count = signal(0);
const doubled = signal(0);
effect(() => {
doubled.set(count() * 2); // Don't do this!
});
// ā
GOOD: Use computed
const doubled = computed(() => count() * 2);
ā Pitfall 3: Creating Effects Outside Injection Context
// ā BAD: Effect outside constructor
export class MyComponent {
count = signal(0);
ngOnInit() {
effect(() => console.log(this.count())); // Error!
}
}
// ā
GOOD: Effect in constructor or with explicit injector
import { Injector, inject } from '@angular/core';
export class MyComponent {
private injector = inject(Injector);
count = signal(0);
ngOnInit() {
effect(() => console.log(this.count()), {
injector: this.injector
});
}
}
ā Pitfall 4: Forgetting to Call Signal as Function
// ā BAD: Accessing signal without calling it
const count = signal(0);
console.log(count); // Logs the signal object, not the value
// ā
GOOD: Call signal to get value
console.log(count()); // Logs 0
ā Pitfall 5: Over-using Signals
// ā BAD: Signal for static data
export class Component {
config = signal({ apiUrl: 'https://api.example.com' }); // Overkill!
}
// ā
GOOD: Use regular properties for static data
export class Component {
readonly config = { apiUrl: 'https://api.example.com' };
}
ā Pitfall 6: Infinite Effect Loops
// ā BAD: Effect that modifies its own dependencies
const count = signal(0);
effect(() => {
console.log(count());
count.set(count() + 1); // Infinite loop!
});
// ā
GOOD: Effects should only read signals, not modify them
effect(() => {
console.log(count());
// Perform side effects, don't modify dependencies
});
ā Pitfall 7: Not Handling Async Properly
// ā BAD: Async operation in effect
const userId = signal(1);
effect(async () => {
const data = await fetch(`/api/users/${userId()}`);
// Effect doesn't track async dependencies properly
});
// ā
GOOD: Use dedicated pattern
const userId = signal(1);
const userData = signal(null);
effect(() => {
const id = userId(); // Track dependency
fetch(`/api/users/${id}`)
.then(data => userData.set(data));
});
Decision Tree: When to Use What?
Performance Comparison
| Scenario | Traditional | Signals | Performance Gain |
|---|---|---|---|
| Large list updates | Full component check | Targeted updates | 3-5x faster |
| Deeply nested components | Full tree check | Only affected paths | 5-10x faster |
| Computed values | Re-run on every CD | Memoized | 10-100x faster |
| Multiple derived values | Multiple calculations | Cached computed | 2-20x faster |
Migration Strategy
Phase 1: New Features
// Start using signals in new components
export class NewComponent {
// ā
Use signals for new code
count = signal(0);
doubled = computed(() => this.count() * 2);
}
Phase 2: Gradual Refactoring
// Refactor hot paths and performance-critical components
export class ExistingComponent {
// Before: @Input() userId!: number;
// After:
userId = input.required<number>();
// Before: get userName() { ... }
// After:
userName = computed(() => { ... });
}
Phase 3: Full Signal Components
// Eventually: fully signal-based with OnPush
@Component({
selector: 'app-optimized',
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
export class OptimizedComponent {
// All reactive state as signals
}
Best Practices Checklist
ā DO:
- Use
signal()for mutable state - Use
computed()for derived values - Use
effect()only for side effects - Keep signals immutable (create new references)
- Use
input()for component inputs - Use
model()for two-way binding - Create effects in injection context
- Use descriptive signal names
ā DON'T:
- Mutate signal values directly
- Use effects for derived state
- Create infinite effect loops
- Forget to call signals as functions
- Overuse signals for static data
- Modify signals inside computed
- Create effects outside injection context
Real-World Example: Todo Application
import { Component, signal, computed, effect } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
type Filter = 'all' | 'active' | 'completed';
@Component({
selector: 'app-todo',
template: `
<div class="todo-app">
<input
#newTodo
(keyup.enter)="addTodo(newTodo.value); newTodo.value = ''"
placeholder="What needs to be done?" />
<select [(ngModel)]="filter">
<option value="all">All ({{ todos().length }})</option>
<option value="active">Active ({{ activeCount() }})</option>
<option value="completed">Completed ({{ completedCount() }})</option>
</select>
<ul>
<li *ngFor="let todo of filteredTodos()"
[class.completed]="todo.completed"
[class.priority-high]="todo.priority === 'high'">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)" />
<span>{{ todo.text }}</span>
<button (click)="removeTodo(todo.id)">Delete</button>
</li>
</ul>
<div class="stats">
<p>Progress: {{ progressPercent() }}%</p>
<p>High Priority: {{ highPriorityCount() }}</p>
</div>
</div>
`
})
export class TodoAppComponent {
// State
todos = signal<Todo[]>(this.loadFromStorage());
filter = signal<Filter>('all');
// Computed values
activeCount = computed(() =>
this.todos().filter(t => !t.completed).length
);
completedCount = computed(() =>
this.todos().filter(t => t.completed).length
);
filteredTodos = computed(() => {
const filter = this.filter();
const todos = this.todos();
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
progressPercent = computed(() => {
const total = this.todos().length;
if (total === 0) return 0;
return Math.round((this.completedCount() / total) * 100);
});
highPriorityCount = computed(() =>
this.todos().filter(t => !t.completed && t.priority === 'high').length
);
constructor() {
// Side effect: persist to localStorage
effect(() => {
const todos = this.todos();
localStorage.setItem('todos', JSON.stringify(todos));
console.log(`Saved ${todos.length} todos`);
});
}
// Actions
addTodo(text: string) {
if (!text.trim()) return;
this.todos.update(current => [
...current,
{
id: Date.now(),
text: text.trim(),
completed: false,
priority: 'medium'
}
]);
}
toggleTodo(id: number) {
this.todos.update(current =>
current.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
removeTodo(id: number) {
this.todos.update(current => current.filter(t => t.id !== id));
}
private loadFromStorage(): Todo[] {
try {
return JSON.parse(localStorage.getItem('todos') || '[]');
} catch {
return [];
}
}
}
Conclusion
Angular Signals represent the future of reactive programming in Angular. They offer:
š Better Performance - Fine-grained reactivity with minimal overhead
šÆ Improved DX - Simpler APIs, better TypeScript support
š¦ Smaller Bundles - Less framework code needed
š® Future-proof - Foundation for Angular's evolution
Next Steps
- Start Small: Use signals in new components
- Learn Patterns: Master computed and effect
- Refactor Gradually: Migrate performance-critical code
- Stay Updated: Follow Angular's signal roadmap
Resources
Happy coding with Signals! š