ð angular-signals
Use when building Angular 16+ applications requiring fine-grained reactive state management and zone-less change detection.
Overview
Master Angular Signals for building reactive applications with fine-grained reactivity and improved performance.
Signal Basics
Creating and Using Signals
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Writable signal
count = signal(0);
// Computed signal
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect runs when count changes
effect(() => {
console.log(`Count is: ${this.count()}`);
});
}
increment() {
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
this.count.set(0);
}
}
Signal Methods
import { signal } from '@angular/core';
// Create signal
const count = signal(0);
// set - replace value
count.set(5);
// update - transform current value
count.update(value => value + 1);
// mutate - modify object (experimental)
const user = signal({ name: 'John', age: 30 });
user.mutate(value => {
value.age = 31; // Mutate in place
});
// Read value
const current = count(); // Call as function
Computed Signals
Basic Computed
import { signal, computed } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
// Computed signal
const fullName = computed(() => {
return `${firstName()} ${lastName()}`;
});
console.log(fullName()); // John Doe
firstName.set('Jane');
console.log(fullName()); // Jane Doe (automatically updates)
Complex Computed
interface Product {
id: number;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart'
})
export class CartComponent {
items = signal<Product[]>([]);
// Computed: total items
itemCount = computed(() => {
return this.items().reduce((sum, item) => sum + item.quantity, 0);
});
// Computed: subtotal
subtotal = computed(() => {
return this.items().reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
});
// Computed: tax
tax = computed(() => this.subtotal() * 0.08);
// Computed: total
total = computed(() => this.subtotal() + this.tax());
// Computed: formatted total
formattedTotal = computed(() => {
return `$${this.total().toFixed(2)}`;
});
}
Chained Computed
const count = signal(1);
const doubled = computed(() => count() * 2);
const quadrupled = computed(() => doubled() * 2);
const formatted = computed(() => `Count: ${quadrupled()}`);
console.log(formatted()); // Count: 4
count.set(2);
console.log(formatted()); // Count: 8
Effects
Basic Effects
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-logger'
})
export class LoggerComponent {
count = signal(0);
constructor() {
// Effect runs when count changes
effect(() => {
console.log(`Count changed to: ${this.count()}`);
});
}
increment() {
this.count.update(v => v + 1); // Triggers effect
}
}
Effect Cleanup
import { effect } from '@angular/core';
const count = signal(0);
effect((onCleanup) => {
const timer = setInterval(() => {
console.log(count());
}, 1000);
// Cleanup function
onCleanup(() => {
clearInterval(timer);
});
});
Conditional Effects
import { effect, signal } from '@angular/core';
const enabled = signal(true);
const count = signal(0);
effect(() => {
// Only run if enabled
if (!enabled()) return;
console.log(`Count: ${count()}`);
});
Signal Inputs
Component Inputs as Signals
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-profile',
template: `
<div>
<h2>{{ displayName() }}</h2>
<p>Age: {{ age() }}</p>
<p>Is adult: {{ isAdult() }}</p>
</div>
`
})
export class UserProfileComponent {
// Signal inputs (Angular 17.1+)
firstName = input.required<string>();
lastName = input.required<string>();
age = input(0); // Optional with default
// Computed from inputs
displayName = computed(() =>
`${this.firstName()} ${this.lastName()}`
);
isAdult = computed(() => this.age() >= 18);
}
// Usage
<app-user-profile
[firstName]="'John'"
[lastName]="'Doe'"
[age]="30"
/>
Transform Input Signals
import { Component, input } from '@angular/core';
@Component({
selector: 'app-formatted-text'
})
export class FormattedTextComponent {
// Transform input
text = input('', {
transform: (value: string) => value.toUpperCase()
});
// Alias input
label = input('', { alias: 'labelText' });
}
// Usage
<app-formatted-text
[text]="'hello'"
[labelText]="'Name'"
/>
Signal Outputs
Component Outputs as Signals
import { Component, output } from '@angular/core';
@Component({
selector: 'app-button',
template: `
<button (click)="handleClick()">
{{ label() }}
</button>
`
})
export class ButtonComponent {
label = input('Click me');
// Signal output (Angular 17.1+)
clicked = output<void>();
valueChanged = output<number>();
private clickCount = signal(0);
handleClick() {
this.clickCount.update(v => v + 1);
this.clicked.emit();
this.valueChanged.emit(this.clickCount());
}
}
// Usage
<app-button
(clicked)="onClicked()"
(valueChanged)="onValueChanged($event)"
/>
Signal Queries
ViewChild with Signals
import { Component, viewChild, ElementRef, afterNextRender } from '@angular/core';
@Component({
selector: 'app-input-focus',
template: `
<input #inputElement type="text" />
<button (click)="focusInput()">Focus</button>
`
})
export class InputFocusComponent {
// Signal-based viewChild
inputElement = viewChild<ElementRef>('inputElement');
constructor() {
afterNextRender(() => {
// Access element after render
const element = this.inputElement()?.nativeElement;
if (element) {
element.focus();
}
});
}
focusInput() {
this.inputElement()?.nativeElement.focus();
}
}
ViewChildren with Signals
import { Component, viewChildren, ElementRef } from '@angular/core';
@Component({
selector: 'app-list',
template: `
<div #item *ngFor="let item of items()">
{{ item }}
</div>
<p>Item count: {{ itemElements().length }}</p>
`
})
export class ListComponent {
items = signal(['A', 'B', 'C']);
// Signal-based viewChildren
itemElements = viewChildren<ElementRef>('item');
logItemCount() {
console.log(`Count: ${this.itemElements().length}`);
}
}
ContentChild with Signals
import { Component, contentChild, Directive } from '@angular/core';
@Directive({
selector: '[appHeader]'
})
export class HeaderDirective {}
@Component({
selector: 'app-card',
template: `
<div class="card">
<ng-content select="[appHeader]" />
<ng-content />
<p *ngIf="hasHeader()">Has custom header</p>
</div>
`
})
export class CardComponent {
// Signal-based contentChild
header = contentChild(HeaderDirective);
hasHeader = computed(() => !!this.header());
}
// Usage
<app-card>
<h2 appHeader>Title</h2>
<p>Content</p>
</app-card>
Signals vs Observables
When to Use Signals
// Use signals for synchronous state
@Component({
selector: 'app-counter'
})
export class CounterComponent {
count = signal(0); // Signal for synchronous state
increment() {
this.count.update(v => v + 1);
}
}
When to Use Observables
// Use observables for async operations
@Component({
selector: 'app-user-list'
})
export class UserListComponent {
private http = inject(HttpClient);
users$: Observable<User[]> = this.http.get<User[]>('/api/users');
}
Combining Signals and Observables
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs/operators';
@Component({
selector: 'app-search'
})
export class SearchComponent {
private http = inject(HttpClient);
// Signal for search query
searchQuery = signal('');
// Convert signal to observable
searchQuery$ = toObservable(this.searchQuery);
// Use observable operators
results$ = this.searchQuery$.pipe(
debounceTime(300),
switchMap(query => this.http.get(`/api/search?q=${query}`))
);
// Convert back to signal
results = toSignal(this.results$, { initialValue: [] });
}
toSignal and toObservable
toSignal - Observable to Signal
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="users()">
<div *ngFor="let user of users()">
{{ user.name }}
</div>
</div>
`
})
export class UserListComponent {
private http = inject(HttpClient);
// Convert observable to signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] as User[] }
);
}
toObservable - Signal to Observable
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs/operators';
@Component({
selector: 'app-search'
})
export class SearchComponent {
searchTerm = signal('');
// Convert signal to observable
searchTerm$ = toObservable(this.searchTerm);
constructor() {
// Use observable operators
this.searchTerm$.pipe(
debounceTime(300)
).subscribe(term => {
console.log('Searching for:', term);
});
}
}
Signal Equality and Change Detection
Custom Equality Function
import { signal } from '@angular/core';
interface User {
id: number;
name: string;
}
// Custom equality check
const user = signal<User>(
{ id: 1, name: 'John' },
{
equal: (a, b) => a.id === b.id // Only compare IDs
}
);
user.set({ id: 1, name: 'Jane' }); // No update (same ID)
user.set({ id: 2, name: 'John' }); // Updates (different ID)
Zone-less Change Detection
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
// Signal updates trigger change detection automatically
this.count.update(v => v + 1);
}
}
Migration from Observables
Before - Observables
import { Component } from '@angular/core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-cart'
})
export class CartComponentOld {
private items$ = new BehaviorSubject<Product[]>([]);
private discount$ = new BehaviorSubject<number>(0);
total$ = combineLatest([this.items$, this.discount$]).pipe(
map(([items, discount]) => {
const subtotal = items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
return subtotal * (1 - discount);
})
);
addItem(item: Product) {
this.items$.next([...this.items$.value, item]);
}
}
After - Signals
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart'
})
export class CartComponent {
items = signal<Product[]>([]);
discount = signal(0);
total = computed(() => {
const subtotal = this.items().reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
return subtotal * (1 - this.discount());
});
addItem(item: Product) {
this.items.update(items => [...items, item]);
}
}
When to Use This Skill
Use angular-signals when building modern, production-ready applications that require:
- Fine-grained reactivity without RxJS
- Simpler state management
- Zone-less change detection
- Better performance for synchronous state
- Cleaner component code
- Angular 16+ applications
- Migrating from observables for sync state
- Component input/output as signals
Signal Best Practices
- Use signals for synchronous state - Perfect for component state
- Use computed for derived values - Automatic dependency tracking
- Prefer signals over observables for state - Simpler mental model
- Use effects sparingly - Only for side effects
- Signal inputs for better types - Type-safe component props
- Combine with observables when needed - Use toSignal/toObservable
- Use custom equality for objects - Optimize updates
- Leverage zone-less change detection - Better performance
- Keep signals focused - Small, single-purpose signals
- Use mutate carefully - Prefer update for immutability
Signal Pitfalls
- Overusing effects - Can create complex dependencies
- Mutating signal values directly - Use update/mutate methods
- Not understanding equality - Objects update by reference
- Mixing patterns - Choose signals OR observables per feature
- Effects in loops - Can cause performance issues
- Not cleaning up effects - Memory leaks
- Computed with side effects - Should be pure functions
- Reading signals outside tracking context - Won't track dependencies
- Complex effect dependencies - Hard to debug
- Forgetting to call signal -
countvscount()
Advanced Signal Patterns
State Management Pattern
import { signal, computed } from '@angular/core';
interface TodoState {
items: Todo[];
filter: 'all' | 'active' | 'completed';
}
@Injectable({
providedIn: 'root'
})
export class TodoStore {
// Private state
private state = signal<TodoState>({
items: [],
filter: 'all'
});
// Public selectors
items = computed(() => this.state().items);
filter = computed(() => this.state().filter);
filteredItems = computed(() => {
const items = this.items();
const filter = this.filter();
switch (filter) {
case 'active':
return items.filter(item => !item.completed);
case 'completed':
return items.filter(item => item.completed);
default:
return items;
}
});
// Actions
addTodo(text: string) {
this.state.update(state => ({
...state,
items: [...state.items, { id: Date.now(), text, completed: false }]
}));
}
toggleTodo(id: number) {
this.state.update(state => ({
...state,
items: state.items.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
)
}));
}
setFilter(filter: TodoState['filter']) {
this.state.update(state => ({ ...state, filter }));
}
}
Signal-based Forms
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-login-form'
})
export class LoginFormComponent {
email = signal('');
password = signal('');
emailError = computed(() => {
const email = this.email();
if (!email) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return 'Invalid email format';
}
return null;
});
passwordError = computed(() => {
const password = this.password();
if (!password) return 'Password is required';
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
});
isValid = computed(() =>
!this.emailError() && !this.passwordError() &&
this.email() && this.password()
);
submit() {
if (!this.isValid()) return;
// Submit form
}
}