Skip to main content

Signals

What are Signals?

Signals are a reactive primitive for managing state in Angular (v16+).

import { Component, signal, computed, effect } from '@angular/core';

@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
// Signal
count = signal(0);

// Computed signal
double = computed(() => this.count() * 2);

// Effect
constructor() {
effect(() => {
console.log('Count changed:', this.count());
});
}

increment() {
this.count.set(this.count() + 1);
// Or
this.count.update(value => value + 1);
}
}

Signal Operations

// Create signal
count = signal(0);
user = signal<User | null>(null);
items = signal<string[]>([]);

// Read value
console.log(this.count()); // 0

// Set value
this.count.set(10);

// Update value
this.count.update(value => value + 1);

// Mutate arrays/objects
this.items.mutate(arr => arr.push('new item'));

// Or better: immutable update
this.items.update(arr => [...arr, 'new item']);

Computed Signals

export class ShoppingCartComponent {
items = signal([
{ name: 'Item 1', price: 10, quantity: 2 },
{ name: 'Item 2', price: 20, quantity: 1 }
]);

// Automatically recalculated when items change
total = computed(() => {
return this.items().reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
});

itemCount = computed(() => this.items().length);

isEmpty = computed(() => this.itemCount() === 0);

addItem(item: CartItem) {
this.items.update(items => [...items, item]);
}
}

Effects

export class UserComponent {
userId = signal(1);

constructor() {
// Runs when userId changes
effect(() => {
console.log('Loading user:', this.userId());
this.loadUser(this.userId());
});

// Cleanup
effect((onCleanup) => {
const subscription = this.someObservable$.subscribe();

onCleanup(() => {
subscription.unsubscribe();
});
});
}
}

Signal Inputs

import { Component, input } from '@angular/core';

@Component({
selector: 'app-user-card',
standalone: true,
template: `
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
`
})
export class UserCardComponent {
// Signal input (Angular 17.1+)
user = input.required<User>();

// Optional with default
showAvatar = input(true);

// Derived value
displayName = computed(() => {
const user = this.user();
return `${user.firstName} ${user.lastName}`;
});
}

// Usage
<app-user-card [user]="currentUser" [showAvatar]="false" />

Signal vs Observable

// Observable (RxJS)
count$ = new BehaviorSubject(0);

this.count$.subscribe(value => {
console.log(value);
});

this.count$.next(1);

// Signal
count = signal(0);

effect(() => {
console.log(this.count());
});

this.count.set(1);

Interop with Observables

import { toSignal, toObservable } from '@angular/core/rxjs-interop';

export class DataComponent {
userService = inject(UserService);

// Observable -> Signal
users = toSignal(this.userService.getUsers(), { initialValue: [] });

// Signal -> Observable
searchTerm = signal('');
searchTerm$ = toObservable(this.searchTerm);

constructor() {
this.searchTerm$.pipe(
debounceTime(300),
switchMap(term => this.userService.search(term))
).subscribe(results => {
// Handle results
});
}
}

Complex State with Signals

interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}

export class TodoService {
private state = signal<TodoState>({
todos: [],
filter: 'all'
});

// Selectors
todos = computed(() => this.state().todos);
filter = computed(() => this.state().filter);

filteredTodos = computed(() => {
const todos = this.todos();
const filter = this.filter();

switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});

// Actions
addTodo(title: string) {
this.state.update(state => ({
...state,
todos: [...state.todos, { id: Date.now(), title, completed: false }]
}));
}

setFilter(filter: TodoState['filter']) {
this.state.update(state => ({ ...state, filter }));
}

toggleTodo(id: number) {
this.state.update(state => ({
...state,
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
}));
}
}

Best Practices

  • Use signals for local component state
  • Use computed for derived values
  • Use effects sparingly (side effects only)
  • Prefer immutable updates over mutate()
  • Use toSignal to bridge observables
  • Signals enable OnPush by default
  • Simpler than RxJS for simple state