Angular Signals: A Deep Dive

Angular Signals represent a significant paradigm shift in how Angular handles reactivity. Introduced in Angular 16 and expanded in subsequent versions, Signals provide a fine-grained reactivity system that brings numerous benefits to Angular applications. This article explores the Signal API in depth, with practical TypeScript examples to help you master this powerful feature.

What Are Signals and Why Should You Care?

Signals are a primitive for reactive programming that hold a value and notify consumers when that value changes. They're designed to be:

  • Lightweight: Minimal overhead when creating and updating values
  • Fine-grained: Only affected components rerender, not the entire view
  • Transparent: Clear connections between reactive values and their dependencies
  • Synchronous: Changes propagate immediately in a predictable manner
  • Compatible: Work alongside existing Angular features without requiring a complete rewrite

Before Signals, Angular's reactivity was primarily based on Zone.js and change detection, which could sometimes be inefficient and opaque. Signals provide a more explicit, efficient alternative.

Core Concepts

At their core, Signals are functions that:

  1. Return their current value when called
  2. Track when they're read during rendering or computation
  3. Notify listeners when their value changes

Let's examine the fundamental building blocks:

1. Creating a Signal

import { signal } from '@angular/core';

// Creating a typed signal
const count = signal<number>(0);

// Creating a signal with inferred type
const name = signal('Angular');

// Reading a signal's value
console.log(count()); // 0
console.log(name()); // 'Angular'

2. Updating a Signal

// Direct value update
count.set(5);

// Update based on previous value
count.update(value => value + 1);

// For objects, use mutate to modify in place
const user = signal({ name: 'Alice', role: 'Admin' });
user.mutate(value => {
  value.role = 'Developer';
});

3. Computed Signals

Computed signals derive their value from other signals:

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

const price = signal(100);
const taxRate = signal(0.1);

// Computed value that updates when dependencies change
const priceWithTax = computed(() => {
  return price() * (1 + taxRate());
});

console.log(priceWithTax()); // 110

// When price changes, priceWithTax automatically updates
price.set(200);
console.log(priceWithTax()); // 220

4. Effect Handling

Effects allow you to respond to signal changes:

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

const user = signal<string | null>(null);

// This effect runs initially and on every change to user
effect(() => {
  if (user()) {
    console.log(`Current user: ${user()}`);
  } else {
    console.log('No user logged in');
  }
});

// Logs: "No user logged in"
user.set('Alice');
// Logs: "Current user: Alice"

Signal Types in TypeScript

Let's look at the TypeScript interfaces that define signals:

// Core Signal interface
export interface Signal<T> {
  (): T;
  [SIGNAL]: true;
}

// Writable Signal interface
export interface WritableSignal<T> extends Signal<T> {
  set(value: T): void;
  update(updateFn: (value: T) => T): void;
  asReadonly(): Signal<T>;
}

// Mutable Signal interface (for objects)
export interface MutableSignal<T extends object> extends WritableSignal<T> {
  mutate(mutatorFn: (value: T) => void): void;
}

Integrating Signals with Components

Angular 17+ provides a first-class integration with components:

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

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <h1>Counter: {{ count() }}</h1>
    <p>Doubled: {{ doubled() }}</p>
    <button (click)="increment()">Increment</button>
    <button (click)="decrement()">Decrement</button>
  `,
})
export class CounterComponent {
  // Component state as signals
  count = signal(0);
  
  // Derived state
  doubled = computed(() => this.count() * 2);
  
  // Methods that update signals
  increment() {
    this.count.update(n => n + 1);
  }
  
  decrement() {
    this.count.update(n => n - 1);
  }
}

Signal Inputs

Angular 17 introduced signal inputs, which provide reactive inputs without the need for ngOnChanges:

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

@Component({
  selector: 'app-greeting',
  standalone: true,
  template: '<h1>Hello, {{ name() }}!</h1>',
})
export class GreetingComponent {
  // Signal input with default value
  name = input('Guest');
  
  // Required input with type safety
  userId = input.required<number>();
  
  // Input with transform function
  age = input(0, {
    transform: (value: string | number) => 
      typeof value === 'string' ? parseInt(value, 10) : value
  });
}

Signal Queries

For DOM queries, Angular provides signal-based alternatives to @ViewChild and @ViewChildren:

import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
import { ListItemComponent } from './list-item.component';

@Component({
  selector: 'app-list',
  standalone: true,
  imports: [ListItemComponent],
  template: `
    <h1 #title>Items</h1>
    <app-list-item *ngFor="let item of items" [item]="item"></app-list-item>
  `,
})
export class ListComponent {
  items = ['Apple', 'Banana', 'Cherry'];
  
  // Signal-based ViewChild
  titleElement = viewChild<ElementRef>('title');
  
  // Signal-based ViewChildren
  listItems = viewChildren(ListItemComponent);
  
  ngAfterViewInit() {
    // Access as signals
    console.log(this.titleElement()?.nativeElement.textContent);
    console.log('Item count:', this.listItems().length);
  }
}

Signals vs. RxJS: When to Use Each

Both Signals and RxJS handle reactivity, but they serve different purposes:

Aspect Signals RxJS
Complexity Simple values with dependencies Complex event streams
Learning Curve Lower Higher
Use Cases UI state, derived values Async operations, event handling
Debugging Easier to trace More powerful but complex
Operators Limited built-in Rich ecosystem of operators

Consider using:

  • Signals for component state, UI interactions, and derived values
  • RxJS for HTTP requests, complex event sequences, and time-based operations

They can be used together:

import { signal } from '@angular/core';
import { timer } from 'rxjs';

const counter = signal(0);

// Connect RxJS Observable to a Signal
const subscription = timer(0, 1000).subscribe(value => {
  counter.set(value);
});

// Later: subscription.unsubscribe();

Advanced Patterns with Signals

1. Signal-based Services

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

interface User {
  id: number;
  name: string;
  isAdmin: boolean;
}

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private userSignal = signal<User | null>(null);
  
  // Public readonly APIs
  readonly user = this.userSignal.asReadonly();
  readonly isLoggedIn = computed(() => this.userSignal() !== null);
  readonly isAdmin = computed(() => this.userSignal()?.isAdmin ?? false);
  
  login(user: User): void {
    this.userSignal.set(user);
  }
  
  logout(): void {
    this.userSignal.set(null);
  }
  
  updateUser(updates: Partial<User>): void {
    this.userSignal.update(user => {
      if (!user) return null;
      return { ...user, ...updates };
    });
  }
}

2. Complex Form State

import { Component, signal, computed } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-registration',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <!-- Form controls here -->
      <button type="submit" [disabled]="isSubmitDisabled()">
        {{ submitButtonText() }}
      </button>
    </form>
  `
})
export class RegistrationComponent {
  form: FormGroup;
  isSubmitting = signal(false);
  
  // Computed properties based on form and submission state
  isSubmitDisabled = computed(() => 
    this.form.invalid || this.isSubmitting()
  );
  
  submitButtonText = computed(() => 
    this.isSubmitting() ? 'Submitting...' : 'Register'
  );
  
  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]]
    });
  }
  
  onSubmit() {
    if (this.form.invalid) return;
    
    this.isSubmitting.set(true);
    // Submit logic here
    
    // Simulate API call
    setTimeout(() => {
      this.isSubmitting.set(false);
    }, 1500);
  }
}

3. Server State Management

import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export type RequestStatus = 'idle' | 'loading' | 'success' | 'error';

export interface RequestState<T> {
  status: RequestStatus;
  data: T | null;
  error: any;
}

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private productsState = signal<RequestState<Product[]>>({
    status: 'idle',
    data: null,
    error: null
  });
  
  // Public APIs
  readonly products = computed(() => this.productsState().data || []);
  readonly status = computed(() => this.productsState().status);
  readonly isLoading = computed(() => this.status() === 'loading');
  readonly error = computed(() => this.productsState().error);
  
  constructor(private http: HttpClient) {}
  
  fetchProducts() {
    // Set loading state
    this.productsState.set({
      status: 'loading',
      data: this.productsState().data, // Keep existing data
      error: null
    });
    
    this.http.get<Product[]>('/api/products').subscribe({
      next: (data) => {
        this.productsState.set({
          status: 'success',
          data,
          error: null
        });
      },
      error: (error) => {
        this.productsState.set({
          status: 'error',
          data: this.productsState().data,
          error
        });
      }
    });
  }
}

interface Product {
  id: number;
  name: string;
  price: number;
}

Performance Considerations

Signals offer performance benefits, but require careful consideration:

  1. Minimize Signal Reads in Templates
    Too many signal reads can degrade performance.

    // Inefficient - multiple reads in template
    <div>{{ user().firstName }} {{ user().lastName }}</div>
    
    // Better - one signal read
    <div>{{ fullName() }}</div>
    
    // In component
    user = signal({ firstName: 'John', lastName: 'Doe' });
    fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);
    
  2. Avoid Expensive Computations
    Computed signals should be lightweight.

    // Inefficient - expensive operation on every read
    expensiveData = computed(() => {
      return this.items().filter(item => 
        complexCalculation(item)
      );
    });
    
    // Better - cache intermediate results
    processedItems = computed(() => {
      const items = this.items();
      if (this.lastItemsLength === items.length) {
        return this.cachedResult;
      }
      this.lastItemsLength = items.length;
      this.cachedResult = items.filter(item => complexCalculation(item));
      return this.cachedResult;
    });
    
  3. Use untracked() When Necessary
    To prevent unnecessary recomputation:

    import { signal, computed, untracked } from '@angular/core';
    
    const counter = signal(0);
    const logger = signal<string[]>([]);
    
    // Without untracked, this would recompute when logger changes
    const displayValue = computed(() => {
      const count = counter();
      // Access logger without creating a dependency
      untracked(() => {
        console.log('Current logs:', logger().length);
      });
      return `Counter: ${count}`;
    });
    

Best Practices

1. Signal Naming Conventions

// Component state signals
count = signal(0);
isLoading = signal(false);
users = signal<User[]>([]);

// Computed values - descriptive names showing derivation
totalUsers = computed(() => this.users().length);
hasUsers = computed(() => this.totalUsers() > 0);
filteredUsers = computed(() => /* filtering logic */);

// Service APIs - use readonly for public signals
readonly currentUser = this.userSignal.asReadonly();

2. Enforce Immutability

// DO: Create new objects when updating signals
updateUser(id: number, updates: Partial<User>) {
  this.users.update(users => 
    users.map(user => 
      user.id === id ? { ...user, ...updates } : user
    )
  );
}

// DON'T: Mutate objects outside signal operations
const users = this.users();
users[0].name = 'New Name'; // ❌ Won't trigger updates

// DO: Use mutate for performance when appropriate
this.users.mutate(users => {
  users[0].name = 'New Name';
});

3. Organizing Complex Component State

For components with complex state, consider encapsulating related signals:

interface UserState {
  users: User[];
  selectedId: number | null;
  filter: string;
  isLoading: boolean;
}

@Component({
  selector: 'app-user-dashboard',
  template: `<!-- template here -->`
})
export class UserDashboardComponent {
  // Group related state
  private state = signal<UserState>({
    users: [],
    selectedId: null,
    filter: '',
    isLoading: false
  });
  
  // Expose computed properties
  readonly users = computed(() => this.state().users);
  readonly selectedUser = computed(() => {
    const id = this.state().selectedId;
    return id !== null 
      ? this.state().users.find(u => u.id === id) || null
      : null;
  });
  readonly filteredUsers = computed(() => {
    const filter = this.state().filter.toLowerCase();
    return filter
      ? this.state().users.filter(u => 
          u.name.toLowerCase().includes(filter))
      : this.state().users;
  });
  
  // Methods to update state
  setFilter(filter: string) {
    this.state.update(s => ({ ...s, filter }));
  }
  
  selectUser(id: number | null) {
    this.state.update(s => ({ ...s, selectedId: id }));
  }
}

Migrating from RxJS to Signals

If you're transitioning from RxJS to Signals, here's a pattern for gradual migration:

import { Injectable, signal, computed, effect } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class CounterService {
  // Legacy RxJS approach
  private counterSubject = new BehaviorSubject(0);
  counter$ = this.counterSubject.asObservable();
  
  // New Signal approach
  private counterSignal = signal(0);
  readonly counter = this.counterSignal.asReadonly();
  readonly doubledCounter = computed(() => this.counterSignal() * 2);
  
  constructor() {
    // Bridge from RxJS to Signals
    this.counter$.subscribe(value => {
      this.counterSignal.set(value);
    });
    
    // Bridge from Signals to RxJS
    effect(() => {
      const value = this.counterSignal();
      if (value !== this.counterSubject.value) {
        this.counterSubject.next(value);
      }
    });
  }
  
  increment() {
    // Update both for compatibility
    const newValue = this.counterSubject.value + 1;
    this.counterSubject.next(newValue);
    // Signal is updated via the subscription
  }
}

Future of Signals in Angular

Angular is committed to expanding the Signal-based architecture:

  1. More Signal-Based APIs: Expect more of Angular's core to adopt Signals
  2. Signal-Based Router: Angular 18+ brings a router driven by signals
  3. Observable Interoperability: Better bridges between Signals and RxJS
  4. Server-Side Integration: Signal-optimized server-side rendering
  5. Compiler Optimizations: Smart detection and optimization of signals

The Gist

Angular Signals represent a major evolution in Angular's reactivity system, bringing fine-grained updates and clearer reactive programming patterns to the framework. While they don't replace RxJS entirely, they offer an elegant solution for component state management and UI reactivity.

By embracing Signals in your Angular applications, you can:

  • Create more performant applications with less boilerplate
  • Build more maintainable and predictable components
  • Establish clearer relationships between state and derived values
  • Gradually migrate from zone-based change detection to a more explicit reactive model

As you implement Signals in your applications, focus on establishing clear patterns, maintaining immutability, and balancing between Signals and RxJS for the right use cases. With these practices in mind, you'll be well-positioned to leverage the full power of Angular's modern reactivity system.

Additional Resources

Read more