# Angular Signals Patterns **Version:** Angular 16+ **Status:** Stable (Developer Preview in 16, Stable in 17+) **Purpose:** Modern reactive state management without Zone.js overhead --- ## Core Concept **Signals** are Angular's modern approach to reactive state management. Unlike observables, signals are synchronous, glitch-free, and optimized for change detection. **Key Benefits:** - ✅ Simpler mental model than RxJS - ✅ Better performance (no Zone.js overhead) - ✅ Fine-grained reactivity - ✅ Type-safe - ✅ Works seamlessly with OnPush change detection --- ## Signal Basics ### Creating Signals ```typescript import { signal, computed, effect } from '@angular/core'; // Writable signal const count = signal(0); // number signal const name = signal('John'); // string signal const user = signal(null); // object signal with null // Read value (call as function) console.log(count()); // 0 console.log(name()); // "John" // Write value count.set(5); // Set to exact value name.set('Jane'); // Update value (based on current) count.update(n => n + 1); // Increment ``` ### Computed Signals Computed signals automatically recalculate when dependencies change: ```typescript const count = signal(0); // Derived value const double = computed(() => count() * 2); const isEven = computed(() => count() % 2 === 0); const message = computed(() => `Count is ${count()} and ${isEven() ? 'even' : 'odd'}` ); console.log(double()); // 0 console.log(message()); // "Count is 0 and even" count.set(3); console.log(double()); // 6 console.log(message()); // "Count is 3 and odd" ``` ### Effects Effects run side effects when signals change: ```typescript import { effect } from '@angular/core'; const count = signal(0); // Effect runs when count changes effect(() => { console.log(`Count changed to: ${count()}`); localStorage.setItem('count', count().toString()); }); count.set(5); // Logs: "Count changed to: 5" ``` --- ## Pattern 1: Component State ### Basic Component State ```typescript import { Component, signal, computed } from '@angular/core'; @Component({ selector: 'app-todo-list', standalone: true, template: `

Total: {{ total() }} | Active: {{ active() }} | Completed: {{ completed() }}

@for (todo of todos(); track todo.id) {
{{ todo.text }}
} ` }) export class TodoListComponent { // State todos = signal([]); newTodo = signal(''); // Computed total = computed(() => this.todos().length); active = computed(() => this.todos().filter(t => !t.completed).length); completed = computed(() => this.todos().filter(t => t.completed).length); addTodo() { if (this.newTodo().trim()) { this.todos.update(todos => [ ...todos, { id: Date.now().toString(), text: this.newTodo(), completed: false } ]); this.newTodo.set(''); } } toggle(id: string) { this.todos.update(todos => todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t) ); } remove(id: string) { this.todos.update(todos => todos.filter(t => t.id !== id)); } } ``` --- ## Pattern 2: Derived State ### Computed from Multiple Signals ```typescript @Component({...}) export class ShoppingCartComponent { items = signal([]); taxRate = signal(0.08); shippingCost = signal(5.99); // Computed from items subtotal = computed(() => this.items().reduce((sum, item) => sum + item.price * item.quantity, 0) ); // Computed from subtotal and taxRate tax = computed(() => this.subtotal() * this.taxRate()); // Computed from multiple signals total = computed(() => this.subtotal() + this.tax() + this.shippingCost() ); // Computed boolean hasItems = computed(() => this.items().length > 0); canCheckout = computed(() => this.hasItems() && this.total() > 0 ); } ``` --- ## Pattern 3: Signal Arrays ### Immutable Array Updates ```typescript @Component({...}) export class ListComponent { items = signal([]); // Add item addItem(item: Item) { this.items.update(current => [...current, item]); } // Remove item removeItem(id: string) { this.items.update(current => current.filter(item => item.id !== id)); } // Update item updateItem(id: string, updates: Partial) { this.items.update(current => current.map(item => item.id === id ? { ...item, ...updates } : item ) ); } // Sort items sortBy(key: keyof Item) { this.items.update(current => [...current].sort((a, b) => a[key] > b[key] ? 1 : -1) ); } // Filter items filteredItems = computed(() => this.items().filter(item => item.active) ); } ``` --- ## Pattern 4: Signal Objects ### Nested Object Updates ```typescript interface User { id: string; name: string; email: string; preferences: { theme: 'light' | 'dark'; language: string; }; } @Component({...}) export class UserProfileComponent { user = signal({ id: '1', name: 'John', email: 'john@example.com', preferences: { theme: 'light', language: 'en' } }); // Update top-level property updateName(name: string) { this.user.update(u => ({ ...u, name })); } // Update nested property updateTheme(theme: 'light' | 'dark') { this.user.update(u => ({ ...u, preferences: { ...u.preferences, theme } })); } // Computed from nested property isDarkMode = computed(() => this.user().preferences.theme === 'dark'); } ``` --- ## Pattern 5: Loading States ### Common Loading Pattern ```typescript interface LoadingState { loading: boolean; data: T | null; error: string | null; } @Component({...}) export class DataComponent { private http = inject(HttpClient); state = signal>({ loading: false, data: null, error: null }); // Computed isLoading = computed(() => this.state().loading); hasError = computed(() => this.state().error !== null); hasData = computed(() => this.state().data !== null); products = computed(() => this.state().data ?? []); loadData() { this.state.update(s => ({ ...s, loading: true, error: null })); this.http.get('/api/products').subscribe({ next: data => this.state.set({ loading: false, data, error: null }), error: err => this.state.set({ loading: false, data: null, error: err.message }) }); } } ``` --- ## Pattern 6: Form State ### Signal-Based Form ```typescript @Component({ selector: 'app-signup-form', template: `
@if (emailError()) { {{ emailError() }} }
` }) export class SignupFormComponent { // Form fields email = signal(''); password = signal(''); // Validation emailError = computed(() => { const value = this.email(); if (!value) return 'Email is required'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return 'Invalid email format'; } return null; }); passwordError = computed(() => { const value = this.password(); if (!value) return 'Password is required'; if (value.length < 8) return 'Minimum 8 characters'; return null; }); isValid = computed(() => !this.emailError() && !this.passwordError() ); handleSubmit(event: Event) { event.preventDefault(); if (this.isValid()) { console.log({ email: this.email(), password: this.password() }); } } } ``` --- ## Pattern 7: Signal Effects ### Side Effects with Cleanup ```typescript @Component({...}) export class AutoSaveComponent { content = signal(''); constructor() { // Auto-save effect effect(() => { const data = this.content(); if (data) { const timeoutId = setTimeout(() => { console.log('Auto-saving:', data); this.save(data); }, 1000); // Cleanup function return () => clearTimeout(timeoutId); } }); // Log changes effect(() => { console.log('Content length:', this.content().length); }); } save(data: string) { localStorage.setItem('draft', data); } } ``` --- ## Pattern 8: Signals with RxJS ### Converting Between Signals and Observables ```typescript import { toSignal, toObservable } from '@angular/core/rxjs-interop'; import { interval } from 'rxjs'; @Component({...}) export class MixedComponent { // Observable to Signal private tick$ = interval(1000); tick = toSignal(this.tick$, { initialValue: 0 }); // Signal to Observable count = signal(0); count$ = toObservable(this.count); constructor() { // Subscribe to signal as observable this.count$.subscribe(value => { console.log('Count changed:', value); }); } } ``` ### Combining Signals with HTTP ```typescript @Component({...}) export class UserComponent { private http = inject(HttpClient); userId = signal('123'); // Convert signal to observable, then fetch user = toSignal( toObservable(this.userId).pipe( switchMap(id => this.http.get(`/api/users/${id}`)) ), { initialValue: null } ); } ``` --- ## Pattern 9: Global State with Signals ### Signal-Based Store ```typescript // store.service.ts import { Injectable, signal, computed } from '@angular/core'; export interface AppState { user: User | null; theme: 'light' | 'dark'; notifications: Notification[]; } @Injectable({ providedIn: 'root' }) export class Store { // Private state private state = signal({ user: null, theme: 'light', notifications: [] }); // Public selectors user = computed(() => this.state().user); theme = computed(() => this.state().theme); notifications = computed(() => this.state().notifications); unreadCount = computed(() => this.state().notifications.filter(n => !n.read).length ); // Actions setUser(user: User | null) { this.state.update(s => ({ ...s, user })); } toggleTheme() { this.state.update(s => ({ ...s, theme: s.theme === 'light' ? 'dark' : 'light' })); } addNotification(notification: Notification) { this.state.update(s => ({ ...s, notifications: [...s.notifications, notification] })); } } // Usage in component @Component({...}) export class AppComponent { private store = inject(Store); user = this.store.user; theme = this.store.theme; unreadCount = this.store.unreadCount; logout() { this.store.setUser(null); } } ``` --- ## Best Practices ### Do's ✅ 1. **Use signals for synchronous state** 2. **Prefer computed over manual updates** 3. **Keep signals immutable** - always create new objects/arrays 4. **Use effects for side effects only** 5. **Combine with OnPush** change detection 6. **Use toSignal for observables** in components ### Don'ts ❌ 1. **Don't mutate signal values directly** ```typescript // ❌ Bad items().push(newItem); // ✅ Good items.update(current => [...current, newItem]); ``` 2. **Don't use effects for derived state** ```typescript // ❌ Bad - Use computed instead const count = signal(0); const double = signal(0); effect(() => double.set(count() * 2)); // ✅ Good const double = computed(() => count() * 2); ``` 3. **Don't create signals in loops** 4. **Don't read signals in constructors** (use ngOnInit or effects) --- ## Performance Tips 1. **Computed signals are cached** - only recalculate when dependencies change 2. **Signals trigger change detection only when value changes** 3. **Use OnPush** change detection with signals for best performance 4. **Signals are more efficient than observables** for synchronous state --- ## When to Use Signals vs RxJS **Use Signals for:** - Local component state - Derived/computed values - Form state - UI state (loading, errors) **Use RxJS for:** - Asynchronous operations - HTTP requests - WebSocket streams - Time-based operations (debounce, throttle) - Complex async flows **Use Both:** - Convert observables to signals with `toSignal()` - Convert signals to observables with `toObservable()` --- ## Summary Signals provide: - ✅ Simpler reactive state management - ✅ Better performance - ✅ Type safety - ✅ Fine-grained reactivity - ✅ Seamless integration with Angular **Key Takeaway:** Signals are the future of Angular state management. Use them for synchronous state, computed values, and reactive UI updates.