Angular Signals
Master Angular Signals for building reactive applications with fine-grained reactivity and improved performance.
Signal Basics
Creating and Using Signals
typescriptimport { Component, signal, computed, effect } from '@angular/core'; @Component({ selector: 'app-counter', standalone: true, 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
typescriptimport { 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
typescriptimport { 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
typescriptinterface 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
typescriptconst 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
typescriptimport { 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
typescriptimport { effect } from '@angular/core'; const count = signal(0); effect((onCleanup) => { const timer = setInterval(() => { console.log(count()); }, 1000); // Cleanup function onCleanup(() => { clearInterval(timer); }); });
Conditional Effects
typescriptimport { 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
typescriptimport { Component, input, computed } from '@angular/core'; @Component({ selector: 'app-user-profile', standalone: true, 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
typescriptimport { 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
typescriptimport { 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
typescriptimport { 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
typescriptimport { Component, viewChildren, ElementRef } from '@angular/core'; @Component({ selector: 'app-list', standalone: true, template: ` @for (item of items(); track item) { <div #item>{{ 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
typescriptimport { Component, contentChild, Directive } from '@angular/core'; @Directive({ selector: '[appHeader]' }) export class HeaderDirective {} @Component({ selector: 'app-card', standalone: true, template: ` <div class="card"> <ng-content select="[appHeader]" /> <ng-content /> @if (hasHeader()) { <p>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
typescript// 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
typescript// 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
typescriptimport { 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
typescriptimport { Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-user-list', standalone: true, template: ` @for (user of users(); track user.id) { <div>{{ user.name }}</div> } ` }) export class UserListComponent { private readonly http = inject(HttpClient); // Convert observable to signal users = toSignal( this.http.get<User[]>('/api/users'), { initialValue: [] as User[] } ); }
toObservable - Signal to Observable
typescriptimport { 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
typescriptimport { 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
typescriptimport { 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
typescriptimport { 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
typescriptimport { 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
typescriptimport { 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
typescriptimport { 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 } }

