- name
- angular-signals
- description
- Reactive state management with Angular Signals including signal(), computed(), linkedSignal(), effect(), and RxJS interop. Use when managing component state, creating derived values, handling side effects, or converting between signals and observables in Angular 16+.
Angular Signals
Reactive state management with Angular's signal primitives for fine-grained reactivity.
Basic Signals
Writable Signals
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
// Update with new value
this.count.set(this.count() + 1);
// Or use update function
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
this.count.set(0);
}
}Signal with Objects
import { signal } from '@angular/core';
interface User {
id: number;
name: string;
email: string;
}
export class UserComponent {
user = signal<User>({
id: 1,
name: 'John Doe',
email: ' [email protected] '
});
updateName(name: string) {
// Create new object (immutable update)
this.user.update(user => ({
...user,
name
}));
}
updateEmail(email: string) {
this.user.update(user => ({ ...user, email }));
}
}Signal with Arrays
import { signal } from '@angular/core';
export class TodosComponent {
todos = signal<string[]>([]);
addTodo(text: string) {
this.todos.update(todos => [...todos, text]);
}
removeTodo(index: number) {
this.todos.update(todos =>
todos.filter((_, i) => i !== index)
);
}
clearTodos() {
this.todos.set([]);
}
}Computed Signals
Basic Computed
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart',
template: `
<p>Items: {{ itemCount() }}</p>
<p>Total: {{ total() | currency }}</p>
<p>Average: {{ average() | currency }}</p>
`
})
export class CartComponent {
items = signal([
{ name: 'Item 1', price: 10.99 },
{ name: 'Item 2', price: 24.99 },
{ name: 'Item 3', price: 15.50 }
]);
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
average = computed(() =>
this.itemCount() > 0 ? this.total() / this.itemCount() : 0
);
}Chained Computed Signals
import { signal, computed } from '@angular/core';
export class FilterComponent {
items = signal(['Apple', 'Banana', 'Cherry', 'Date']);
searchTerm = signal('');
sortOrder = signal<'asc' | 'desc'>('asc');
// First computed: filter
filteredItems = computed(() => {
const term = this.searchTerm().toLowerCase();
return this.items().filter(item =>
item.toLowerCase().includes(term)
);
});
// Second computed: sort (depends on filteredItems)
sortedItems = computed(() => {
const items = [...this.filteredItems()];
return this.sortOrder() === 'asc'
? items.sort()
: items.sort().reverse();
});
}Computed with Complex Logic
import { signal, computed } from '@angular/core';
interface Task {
id: number;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
export class TasksComponent {
tasks = signal<Task[]>([]);
filter = signal<'all' | 'active' | 'completed'>('all');
filteredTasks = computed(() => {
const tasks = this.tasks();
const filter = this.filter();
switch (filter) {
case 'active':
return tasks.filter(t => !t.completed);
case 'completed':
return tasks.filter(t => t.completed);
default:
return tasks;
}
});
taskStats = computed(() => {
const tasks = this.tasks();
return {
total: tasks.length,
completed: tasks.filter(t => t.completed).length,
active: tasks.filter(t => !t.completed).length,
highPriority: tasks.filter(t => t.priority === 'high').length
};
});
}Linked Signals (Angular 19+)
Basic Linked Signal
import { Component, signal, linkedSignal } from '@angular/core';
@Component({
selector: 'app-user-editor',
template: `
<input [value]="originalName()" (input)="updateOriginal($event)" />
<input [value]="editedName()" (input)="updateEdited($event)" />
<button (click)="reset()">Reset</button>
`
})
export class UserEditorComponent {
originalName = signal('John Doe');
// editedName is linked to originalName
// When originalName changes, editedName updates
editedName = linkedSignal(() => this.originalName());
updateOriginal(event: Event) {
const input = event.target as HTMLInputElement;
this.originalName.set(input.value);
}
updateEdited(event: Event) {
const input = event.target as HTMLInputElement;
this.editedName.set(input.value);
}
reset() {
// Reset editedName to match originalName
this.editedName.set(this.originalName());
}
}Linked Signal with Transform
import { signal, linkedSignal } from '@angular/core';
export class TemperatureComponent {
celsius = signal(0);
// Fahrenheit is linked to celsius but transformed
fahrenheit = linkedSignal({
source: () => this.celsius(),
computation: (celsius) => (celsius * 9/5) + 32
});
updateCelsius(value: number) {
this.celsius.set(value);
// fahrenheit automatically updates
}
updateFahrenheit(value: number) {
// Can still set fahrenheit directly
this.fahrenheit.set(value);
// But celsius doesn't update automatically
}
}Effects
Basic Effect
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-logger',
template: `
<input [value]="searchTerm()" (input)="updateSearch($event)" />
`
})
export class LoggerComponent {
searchTerm = signal('');
constructor() {
// Effect runs whenever searchTerm changes
effect(() => {
console.log('Search term changed:', this.searchTerm());
});
}
updateSearch(event: Event) {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}Effect with Cleanup
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-timer',
template: `
<p>{{ count() }}</p>
<button (click)="toggleTimer()">
{{ isRunning() ? 'Stop' : 'Start' }}
</button>
`
})
export class TimerComponent {
count = signal(0);
isRunning = signal(false);
constructor() {
effect((onCleanup) => {
if (this.isRunning()) {
const interval = setInterval(() => {
this.count.update(c => c + 1);
}, 1000);
// Cleanup function
onCleanup(() => {
clearInterval(interval);
});
}
});
}
toggleTimer() {
this.isRunning.update(running => !running);
}
}Effect with Dependencies
import { effect, signal } from '@angular/core';
export class DataSyncComponent {
userId = signal(1);
refreshFlag = signal(0);
constructor() {
// Effect depends on both userId and refreshFlag
effect(() => {
const id = this.userId();
const flag = this.refreshFlag(); // Track this too
console.log(`Fetching data for user ${id} (refresh: ${flag})`);
// Fetch user data
});
}
refresh() {
// Trigger effect without changing userId
this.refreshFlag.update(f => f + 1);
}
}RxJS Interop
toSignal - Observable to Signal
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-users',
template: `
@if (users(); as userList) {
@for (user of userList; track user.id) {
<div>{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`
})
export class UsersComponent {
private http = inject(HttpClient);
// Convert Observable to Signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] }
);
}toSignal with Options
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-clock',
template: `<p>{{ time() }}</p>`
})
export class ClockComponent {
time = toSignal(interval(1000), {
initialValue: 0,
requireSync: false, // Don't require synchronous emission
rejectErrors: false // Handle errors gracefully
});
}toObservable - Signal to Observable
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-search',
template: `
<input [value]="searchTerm()" (input)="updateSearch($event)" />
`
})
export class SearchComponent {
searchTerm = signal('');
// Convert signal to observable for RxJS operators
searchTerm$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged()
);
constructor() {
this.searchTerm$.subscribe(term => {
console.log('Debounced search:', term);
// Perform search
});
}
updateSearch(event: Event) {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}Signal Store Pattern
import { Injectable, signal, computed } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
}
@Injectable({ providedIn: 'root' })
export class TodoStore {
// Private state
private todosSignal = signal<Todo[]>([]);
private loadingSignal = signal(false);
private filterSignal = signal<'all' | 'active' | 'completed'>('all');
// Public readonly state
todos = this.todosSignal.asReadonly();
loading = this.loadingSignal.asReadonly();
filter = this.filterSignal.asReadonly();
// Computed values
filteredTodos = computed(() => {
const todos = this.todosSignal();
const filter = this.filterSignal();
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
stats = computed(() => ({
total: this.todosSignal().length,
completed: this.todosSignal().filter(t => t.completed).length,
active: this.todosSignal().filter(t => !t.completed).length
}));
// Actions
addTodo(text: string) {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false
};
this.todosSignal.update(todos => [...todos, newTodo]);
}
toggleTodo(id: number) {
this.todosSignal.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
removeTodo(id: number) {
this.todosSignal.update(todos => todos.filter(t => t.id !== id));
}
setFilter(filter: 'all' | 'active' | 'completed') {
this.filterSignal.set(filter);
}
}Best Practices
- Use
asReadonly()to expose signals publicly from services - Always update signals immutably - create new objects/arrays
- Prefer computed over effects when deriving values
- Use effects for side effects only (logging, persistence, external APIs)
- Keep signal updates synchronous - no async operations in update()
- Use linkedSignal when you need source tracking with manual overrides
- Convert to observables sparingly - stay in signals when possible
- Batch related updates to avoid unnecessary computations
Common Patterns
Loading State
interface LoadingState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export class DataComponent {
state = signal<LoadingState<User[]>>({
data: null,
loading: false,
error: null
});
async loadData() {
this.state.update(s => ({ ...s, loading: true, error: null }));
try {
const data = await fetchUsers();
this.state.update(s => ({ ...s, data, loading: false }));
} catch (error) {
this.state.update(s => ({
...s,
error: error.message,
loading: false
}));
}
}
}Pagination
export class PaginatedListComponent {
items = signal<Item[]>([]);
page = signal(1);
pageSize = signal(10);
paginatedItems = computed(() => {
const start = (this.page() - 1) * this.pageSize();
const end = start + this.pageSize();
return this.items().slice(start, end);
});
totalPages = computed(() =>
Math.ceil(this.items().length / this.pageSize())
);
nextPage() {
this.page.update(p => Math.min(p + 1, this.totalPages()));
}
previousPage() {
this.page.update(p => Math.max(p - 1, 1));
}
}Resources
- Angular Signals Guide: https://angular.dev/guide/signals
- RxJS Interop: https://angular.dev/guide/signals/rxjs-interop