Angular Signals
Signals are Angular's reactive primitive for state management. They provide synchronous, fine-grained reactivity.
Core Signal APIs
signal() - Writable State
typescript
1import { signal } from '@angular/core';
2
3// Create writable signal
4const count = signal(0);
5
6// Read value
7console.log(count()); // 0
8
9// Set new value
10count.set(5);
11
12// Update based on current value
13count.update(c => c + 1);
14
15// With explicit type
16const user = signal<User | null>(null);
17user.set({ id: 1, name: 'Alice' });
computed() - Derived State
typescript
1import { signal, computed } from '@angular/core';
2
3const firstName = signal('John');
4const lastName = signal('Doe');
5
6// Derived signal - automatically updates when dependencies change
7const fullName = computed(() => `${firstName()} ${lastName()}`);
8
9console.log(fullName()); // "John Doe"
10firstName.set('Jane');
11console.log(fullName()); // "Jane Doe"
12
13// Computed with complex logic
14const items = signal<Item[]>([]);
15const filter = signal('');
16
17const filteredItems = computed(() => {
18 const query = filter().toLowerCase();
19 return items().filter(item => item.name.toLowerCase().includes(query));
20});
21
22const totalPrice = computed(() => filteredItems().reduce((sum, item) => sum + item.price, 0));
linkedSignal() - Dependent State with Reset
typescript
1import { signal, linkedSignal } from '@angular/core';
2
3const options = signal(['A', 'B', 'C']);
4
5// Resets to first option when options change
6const selected = linkedSignal(() => options()[0]);
7
8console.log(selected()); // "A"
9selected.set('B'); // User selects B
10console.log(selected()); // "B"
11options.set(['X', 'Y']); // Options change
12console.log(selected()); // "X" - auto-reset to first
13
14// With previous value access
15const items = signal<Item[]>([]);
16
17const selectedItem = linkedSignal<Item[], Item | null>({
18 source: () => items(),
19 computation: (newItems, previous) => {
20 // Try to preserve selection if item still exists
21 const prevItem = previous?.value;
22 if (prevItem && newItems.some(i => i.id === prevItem.id)) {
23 return prevItem;
24 }
25 return newItems[0] ?? null;
26 },
27});
effect() - Side Effects
typescript
1import { signal, effect, inject, DestroyRef } from '@angular/core';
2
3@Component({...})
4export class Search {
5 query = signal('');
6
7 constructor() {
8 // Effect runs when query changes
9 effect(() => {
10 console.log('Search query:', this.query());
11 });
12
13 // Effect with cleanup
14 effect((onCleanup) => {
15 const timer = setInterval(() => {
16 console.log('Current query:', this.query());
17 }, 1000);
18
19 onCleanup(() => clearInterval(timer));
20 });
21 }
22}
Effect rules:
- Run in injection context (constructor or with
runInInjectionContext)
- Automatically cleaned up when component destroys
Component State Pattern
typescript
1@Component({
2 selector: 'app-todo-list',
3 template: `
4 <input [value]="newTodo()" (input)="newTodo.set($any($event.target).value)" />
5 <button (click)="addTodo()" [disabled]="!canAdd()">Add</button>
6
7 <ul>
8 @for (todo of filteredTodos(); track todo.id) {
9 <li [class.done]="todo.done">
10 {{ todo.text }}
11 <button (click)="toggleTodo(todo.id)">Toggle</button>
12 </li>
13 }
14 </ul>
15
16 <p>{{ remaining() }} remaining</p>
17 `,
18})
19export class TodoList {
20 // State
21 todos = signal<Todo[]>([]);
22 newTodo = signal('');
23 filter = signal<'all' | 'active' | 'done'>('all');
24
25 // Derived state
26 canAdd = computed(() => this.newTodo().trim().length > 0);
27
28 filteredTodos = computed(() => {
29 const todos = this.todos();
30 switch (this.filter()) {
31 case 'active':
32 return todos.filter(t => !t.done);
33 case 'done':
34 return todos.filter(t => t.done);
35 default:
36 return todos;
37 }
38 });
39
40 remaining = computed(() => this.todos().filter(t => !t.done).length);
41
42 // Actions
43 addTodo() {
44 const text = this.newTodo().trim();
45 if (text) {
46 this.todos.update(todos => [...todos, { id: crypto.randomUUID(), text, done: false }]);
47 this.newTodo.set('');
48 }
49 }
50
51 toggleTodo(id: string) {
52 this.todos.update(todos => todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
53 }
54}
RxJS Interop
toSignal() - Observable to Signal
typescript
1import { toSignal } from '@angular/core/rxjs-interop';
2import { interval } from 'rxjs';
3
4@Component({...})
5export class Timer {
6 private http = inject(HttpClient);
7
8 // From observable - requires initial value or allowUndefined
9 counter = toSignal(interval(1000), { initialValue: 0 });
10
11 // From HTTP - undefined until loaded
12 users = toSignal(this.http.get<User[]>('/api/users'));
13
14 // With requireSync for synchronous observables (BehaviorSubject)
15 private user$ = new BehaviorSubject<User | null>(null);
16 currentUser = toSignal(this.user$, { requireSync: true });
17}
toObservable() - Signal to Observable
typescript
1import { toObservable } from '@angular/core/rxjs-interop';
2import { switchMap, debounceTime } from 'rxjs';
3
4@Component({...})
5export class Search {
6 query = signal('');
7
8 private http = inject(HttpClient);
9
10 // Convert signal to observable for RxJS operators
11 results = toSignal(
12 toObservable(this.query).pipe(
13 debounceTime(300),
14 switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`))
15 ),
16 { initialValue: [] }
17 );
18}
Signal Equality
typescript
1// Custom equality function
2const user = signal<User>({ id: 1, name: 'Alice' }, { equal: (a, b) => a.id === b.id });
3
4// Only triggers updates when ID changes
5user.set({ id: 1, name: 'Alice Updated' }); // No update
6user.set({ id: 2, name: 'Bob' }); // Triggers update
Untracked Reads
typescript
1import { untracked } from '@angular/core';
2
3const a = signal(1);
4const b = signal(2);
5
6// Only depends on 'a', not 'b'
7const result = computed(() => {
8 const aVal = a();
9 const bVal = untracked(() => b());
10 return aVal + bVal;
11});
Service State Pattern
typescript
1@Injectable({ providedIn: 'root' })
2export class Auth {
3 // Private writable state
4 private _user = signal<User | null>(null);
5 private _loading = signal(false);
6
7 // Public read-only signals
8 readonly user = this._user.asReadonly();
9 readonly loading = this._loading.asReadonly();
10 readonly isAuthenticated = computed(() => this._user() !== null);
11
12 private http = inject(HttpClient);
13
14 async login(credentials: Credentials): Promise<void> {
15 this._loading.set(true);
16 try {
17 const user = await firstValueFrom(this.http.post<User>('/api/login', credentials));
18 this._user.set(user);
19 } finally {
20 this._loading.set(false);
21 }
22 }
23
24 logout(): void {
25 this._user.set(null);
26 }
27}
For advanced patterns including resource(), see references/signal-patterns.md.